Skip to content

Commit fac0190

Browse files
committed
feat: add Sign in with Apple
Tier 1's last sub-item, and deliberately not a fifth mechanical provider case — all three of Apple's differences are handled rather than papered over: - The client "secret" is an ES256 JWT this repo signs itself with the .p8 key from Apple's developer console (apple.go's appleClientSecret), not a static string. APPLE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID and APPLE_PRIVATE_KEY are all required; a partially configured Apple reads as unavailable, like any other unconfigured provider. - There is no userinfo endpoint. The identity comes from the `id_token` in the token response, so it is parsed against Apple's published JWKS with issuer, audience, expiry and RS256 enforced — an unverified decode would let anyone who can reach the callback mint an account. The key set is cached with a TTL and refetched on an unknown kid. - Authorization uses response_mode=query so the existing GET callback route works unchanged. The name/email Apple only sends on a first authorization arrives in the `user` form field under form_post, which this repo does not need: it stores the id_token's email. exchangeCode now takes the client secret as an argument and returns both the access token and the id_token, which is what lets the one Apple path reuse the existing POST plumbing instead of duplicating it. Verified offline by unit tests (apple_test.go): the generated secret parses as a valid ES256 JWT with the console's kid and the right iss/aud/sub; the id_token is accepted when signed by the served key and rejected for a wrong audience, wrong issuer, expiry, a different signing key, an unknown kid, a missing subject, and an `alg: none` token. Real Apple credentials and a live round trip were not available in this sandbox — that is stated in PROGRESS.md and the docs.
1 parent 905116a commit fac0190

6 files changed

Lines changed: 629 additions & 34 deletions

File tree

config/config.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,21 @@ type Config struct {
3535
GitLabClientID string
3636
GitLabClientSecret string
3737

38+
// Apple is the one provider that needs more than an ID and a secret:
39+
// its client "secret" is a short-lived ES256 JWT this repo signs
40+
// itself, with a key downloaded from Apple's developer console, so
41+
// the signing material is configuration here rather than a static
42+
// string. All four must be set for the provider to be available.
43+
//
44+
// AppleClientID is the Services ID (e.g. "com.example.web"), not the
45+
// app bundle ID. ApplePrivateKey is the .p8 file's contents; because
46+
// a PEM cannot sit on one .env line, literal "\n" sequences are
47+
// converted to real newlines when this is loaded.
48+
AppleClientID string
49+
AppleTeamID string
50+
AppleKeyID string
51+
ApplePrivateKey string
52+
3853
// EncryptionKey encrypts TOTP secrets and WebAuthn ceremony state at
3954
// rest. Required only if TOTP or WebAuthn is enabled — cryden refuses
4055
// to construct an engine with either store set and this empty, since a
@@ -123,6 +138,17 @@ func Load() (Config, error) {
123138
cfg.DiscordClientSecret = os.Getenv("DISCORD_CLIENT_SECRET")
124139
cfg.GitLabClientID = os.Getenv("GITLAB_CLIENT_ID")
125140
cfg.GitLabClientSecret = os.Getenv("GITLAB_CLIENT_SECRET")
141+
cfg.AppleClientID = os.Getenv("APPLE_CLIENT_ID")
142+
cfg.AppleTeamID = os.Getenv("APPLE_TEAM_ID")
143+
cfg.AppleKeyID = os.Getenv("APPLE_KEY_ID")
144+
// .env files are line-oriented, so a PEM arrives with its newlines
145+
// written as \n. Only unescape when the value looks like it
146+
// needs it, so a deployment that already passes a real multiline
147+
// value through its own secret manager is left alone.
148+
cfg.ApplePrivateKey = os.Getenv("APPLE_PRIVATE_KEY")
149+
if strings.Contains(cfg.ApplePrivateKey, `\n`) {
150+
cfg.ApplePrivateKey = strings.ReplaceAll(cfg.ApplePrivateKey, `\n`, "\n")
151+
}
126152

127153
// Second factors are optional too, and for the same reason: a
128154
// deployment that hasn't set ENCRYPTION_KEY should still run fine for

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.25.0
44

55
require (
66
github.com/crydensync/cryden/v2 v2.5.0
7+
github.com/golang-jwt/jwt/v5 v5.3.1
78
github.com/lib/pq v1.12.3
89
)
910

@@ -14,7 +15,6 @@ require (
1415
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
1516
github.com/go-webauthn/webauthn v0.18.0 // indirect
1617
github.com/go-webauthn/x v0.3.0 // indirect
17-
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
1818
github.com/google/go-tpm v0.9.8 // indirect
1919
github.com/google/uuid v1.6.0 // indirect
2020
github.com/philhofer/fwd v1.2.0 // indirect

httpapi/apple.go

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package httpapi
2+
3+
import (
4+
"context"
5+
"crypto/ecdsa"
6+
"crypto/rsa"
7+
"crypto/x509"
8+
"encoding/base64"
9+
"encoding/json"
10+
"encoding/pem"
11+
"fmt"
12+
"math/big"
13+
"net/http"
14+
"strings"
15+
"sync"
16+
"time"
17+
18+
"github.com/golang-jwt/jwt/v5"
19+
)
20+
21+
// appleIssuer is both the `iss` Apple puts in an id_token and the `aud`
22+
// our self-signed client secret must carry — Apple is the audience of
23+
// the secret, we are the audience of the id_token.
24+
const appleIssuer = "https://appleid.apple.com"
25+
26+
// appleClientSecretTTL is how long a generated client secret is valid.
27+
// Apple's own limit is six months; ten minutes is short because the
28+
// secret is generated per exchange anyway, so there is nothing to gain
29+
// from a long-lived one — it just means a leaked secret is useless
30+
// almost immediately.
31+
const appleClientSecretTTL = 10 * time.Minute
32+
33+
// appleJWKSURL is a var rather than a const so tests can point it at a
34+
// local server: signature verification is the part of this file worth
35+
// testing, and it cannot be tested against Apple's real endpoint from a
36+
// sandbox with no network.
37+
var appleJWKSURL = appleIssuer + "/auth/keys"
38+
39+
// appleKeyCacheTTL bounds how long a fetched key set is reused. Apple
40+
// rotates signing keys rarely and publishes no cache lifetime we honor,
41+
// so an hour is short enough to pick up a rotation without re-fetching
42+
// per login.
43+
const appleKeyCacheTTL = time.Hour
44+
45+
type appleKeyCache struct {
46+
mu sync.Mutex
47+
keys map[string]*rsa.PublicKey
48+
fetchedAt time.Time
49+
}
50+
51+
var appleKeys appleKeyCache
52+
53+
// appleClientSecret builds the ES256 JWT Apple wants in place of a
54+
// static client secret. This is the whole reason Apple is not another
55+
// mechanical provider case: every other provider hands you a string,
56+
// Apple hands you a key and expects you to sign.
57+
func appleClientSecret(p oauthProvider) (string, error) {
58+
if p.appleTeamID == "" || p.appleKeyID == "" || p.applePrivateKey == "" {
59+
return "", errOAuthProviderNotConfigured
60+
}
61+
62+
key, err := parseApplePrivateKey(p.applePrivateKey)
63+
if err != nil {
64+
return "", err
65+
}
66+
67+
now := time.Now()
68+
tok := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
69+
"iss": p.appleTeamID,
70+
"iat": now.Unix(),
71+
"exp": now.Add(appleClientSecretTTL).Unix(),
72+
"aud": appleIssuer,
73+
"sub": p.clientID,
74+
})
75+
tok.Header["kid"] = p.appleKeyID
76+
return tok.SignedString(key)
77+
}
78+
79+
// parseApplePrivateKey reads the PKCS#8 .p8 file Apple's developer
80+
// console hands out. A non-EC key is rejected here rather than left to
81+
// fail at signing time with a less obvious error.
82+
func parseApplePrivateKey(pemValue string) (*ecdsa.PrivateKey, error) {
83+
block, _ := pem.Decode([]byte(pemValue))
84+
if block == nil {
85+
return nil, fmt.Errorf("httpapi: apple private key is not PEM-encoded")
86+
}
87+
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
88+
if err != nil {
89+
return nil, fmt.Errorf("httpapi: parsing apple private key: %w", err)
90+
}
91+
key, ok := parsed.(*ecdsa.PrivateKey)
92+
if !ok {
93+
return nil, fmt.Errorf("httpapi: apple private key is %T, want an EC (P-256) key", parsed)
94+
}
95+
return key, nil
96+
}
97+
98+
// verifyAppleIDToken checks an id_token against Apple's published
99+
// signing keys and returns the account's stable identifier and email.
100+
// Apple is the only provider here with no userinfo endpoint: the
101+
// identity travels inside this signed JWT, so it is verified rather
102+
// than merely decoded — an unverified parse would let anyone who can
103+
// reach our callback mint an account.
104+
func verifyAppleIDToken(ctx context.Context, idToken, clientID string) (sub, email string, err error) {
105+
claims := jwt.MapClaims{}
106+
_, err = jwt.ParseWithClaims(idToken, claims, func(t *jwt.Token) (any, error) {
107+
kid, _ := t.Header["kid"].(string)
108+
return appleSigningKey(ctx, kid)
109+
},
110+
jwt.WithValidMethods([]string{"RS256"}),
111+
jwt.WithIssuer(appleIssuer),
112+
jwt.WithAudience(clientID),
113+
jwt.WithExpirationRequired(),
114+
)
115+
if err != nil {
116+
return "", "", fmt.Errorf("%w: %v", errOAuthIdentityVerificationFailed, err)
117+
}
118+
119+
sub, _ = claims["sub"].(string)
120+
email, _ = claims["email"].(string)
121+
if sub == "" {
122+
return "", "", errOAuthIdentityVerificationFailed
123+
}
124+
return sub, email, nil
125+
}
126+
127+
// appleSigningKey returns the RSA key for kid, fetching (and caching)
128+
// Apple's key set as needed. A cache miss for a kid we already fetched
129+
// forces one refetch, which is how a key rotation is picked up without
130+
// waiting out the TTL.
131+
func appleSigningKey(ctx context.Context, kid string) (*rsa.PublicKey, error) {
132+
if kid == "" {
133+
return nil, errOAuthIdentityVerificationFailed
134+
}
135+
136+
appleKeys.mu.Lock()
137+
defer appleKeys.mu.Unlock()
138+
139+
_, known := appleKeys.keys[kid]
140+
if known && time.Since(appleKeys.fetchedAt) < appleKeyCacheTTL {
141+
return appleKeys.keys[kid], nil
142+
}
143+
144+
keys, err := fetchAppleKeys(ctx)
145+
if err != nil {
146+
return nil, err
147+
}
148+
appleKeys.keys = keys
149+
appleKeys.fetchedAt = time.Now()
150+
151+
key, ok := keys[kid]
152+
if !ok {
153+
return nil, errOAuthIdentityVerificationFailed
154+
}
155+
return key, nil
156+
}
157+
158+
// fetchAppleKeys reads Apple's JWKS. Only RSA keys are accepted —
159+
// Apple signs id_tokens with RS256, and anything else in the set is not
160+
// something this flow will ever validate against.
161+
func fetchAppleKeys(ctx context.Context) (map[string]*rsa.PublicKey, error) {
162+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, appleJWKSURL, nil)
163+
if err != nil {
164+
return nil, err
165+
}
166+
resp, err := http.DefaultClient.Do(req)
167+
if err != nil {
168+
return nil, err
169+
}
170+
defer resp.Body.Close()
171+
if resp.StatusCode != http.StatusOK {
172+
return nil, fmt.Errorf("httpapi: apple key set request failed: status %d", resp.StatusCode)
173+
}
174+
175+
var body struct {
176+
Keys []struct {
177+
Kty string `json:"kty"`
178+
Kid string `json:"kid"`
179+
N string `json:"n"`
180+
E string `json:"e"`
181+
} `json:"keys"`
182+
}
183+
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
184+
return nil, err
185+
}
186+
187+
out := make(map[string]*rsa.PublicKey, len(body.Keys))
188+
for _, k := range body.Keys {
189+
if k.Kty != "RSA" || k.Kid == "" {
190+
continue
191+
}
192+
nBytes, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(k.N, "="))
193+
if err != nil {
194+
return nil, fmt.Errorf("httpapi: decoding apple key modulus: %w", err)
195+
}
196+
eBytes, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(k.E, "="))
197+
if err != nil {
198+
return nil, fmt.Errorf("httpapi: decoding apple key exponent: %w", err)
199+
}
200+
out[k.Kid] = &rsa.PublicKey{
201+
N: new(big.Int).SetBytes(nBytes),
202+
E: int(new(big.Int).SetBytes(eBytes).Int64()),
203+
}
204+
}
205+
if len(out) == 0 {
206+
return nil, errOAuthIdentityVerificationFailed
207+
}
208+
return out, nil
209+
}

0 commit comments

Comments
 (0)