fix(apple): validate id_token iss and aud on Sign in with Apple#280
Conversation
Coverage Report for CI Build 25600377390Coverage increased (+0.03%) to 84.997%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
bf35ebb to
38f7afc
Compare
14adfc3 to
e6bb5c3
Compare
umputun
left a comment
There was a problem hiding this comment.
substantive concerns:
-
integration test only exercises wrong-
iss, not wrong-aud. The actual confused-deputy shape (sibling Service ID in the same Apple developer team) carries the correctiss = https://appleid.apple.comand a differentaud. The existingTestAppleHandler_LoginHandler_RejectsWrongIssuerwould still pass even if the helper's aud branch were silently broken or the call site somehow only checked iss. The test infra already supports it:testIDTokenOverride{aud: "other.example.com"}. Worth adding a symmetricTestAppleHandler_LoginHandler_RejectsWrongAudiencein bothprovider/apple_test.goandv2/provider/apple_test.go. -
v2 unit test has no multi-element-aud-no-match case. The current "audience as string list with match accepted" case only proves the helper finds a match when one exists in the list. If the v2 loop at
v2/provider/apple.go:573-582were broken toaud[0] == expectedAud, the test would still pass. Addingaud: []interface{}{"other.example.com", "third.example.com"}as awantErrcase closes that. -
v2 helper is replaceable by jwt v5 parser options.
jwt.WithIssuer(appleIDTokenIssuer)+jwt.WithAudience(ah.conf.ClientID)on the existingParseWithClaimsdoes the same checks againstvalidator.expectedIss/validator.expectedAud, with the library's own list-aware matcher (slices.ContainsoverClaimStrings). Drops ~70 lines on the v2 path (helper + unit test + call site). Trade-off is the parse-error branch atv2/provider/apple.go:352-356would now fire 500 for these failures unless mapped viaerrors.Is(err, jwt.ErrTokenInvalidIssuer | ErrTokenInvalidAudience | ErrTokenRequiredClaimMissing)to 403. v1 stays as-is, golang-jwt v3.2.2 has no equivalent. Fine to leave the helper if you prefer the v1/v2 symmetry, just flagging the option. -
minor README nit: the phrase
sibling service in the same Apple developer team ... could replay its own valid tokenreads like the iss check is what stops it, but a sibling shares the same iss. The aud check is what closes it. Could tighten toreplay its own valid token (carrying a different aud)or similar.
9cb6420 to
5971ae6
Compare
|
addressed review feedback in last push:
skipping the v2 jwt v5 parser-options refactor for now -- can do as a follow-up if useful. |
5971ae6 to
5a19c51
Compare
|
folded the v2 jwt v5 parser-options refactor in:
|
umputun
left a comment
There was a problem hiding this comment.
round 1 points all addressed - wrong-aud integration tests in v1+v2, v2 multi-element-no-match (then dropped along with the helper, fine since WithAudience matching is library-tested now), parser-options refactor with errors.Is mapping to 403, README aud-vs-iss framing tightened. Nice that you went the extra mile on the v2 refactor.
one nit - push 2 dropped the validateAppleIDClaims helper from v2 but three test comments still reference it:
v2/provider/apple_test.goTestAppleHandler_LoginHandler_RejectsWrongIssuergodoc: "With the validateAppleIDClaims call in place..."v2/provider/apple_test.goTestAppleHandler_LoginHandler_RejectsWrongAudiencegodoc: "Without the validateAppleIDClaims aud check..."v2/provider/apple_test.gotestIDTokenOverridegodoc: "exercising the happy path through validateAppleIDClaims"
v1 versions of the same comments are correct since v1 still has the helper, only v2 needs the rewording (something like "the parser-options check at the call site" / "the iss/aud parser options").
coveralls -0.3% is artifactual from deleting the TestValidateAppleIDClaims table in v2 - patch coverage is 13/13. not a blocker.
5a19c51 to
34c60a3
Compare
After ParseWithClaims succeeded the Apple handler accepted any token Apple had signed, regardless of which Sign-in-with-Apple client it was issued to. The relying party MUST verify iss == https://appleid.apple.com and aud == ClientID per Apple's spec; we did neither, which let an attacker-controlled Sign-in-with-Apple client (or a sibling service in the same Apple developer team) substitute its own id_token and authenticate as the foreign sub. Add validateAppleIDClaims helper, run it after ParseWithClaims, return 403 with "invalid id_token" on rejection. Same fix applied to v1 (github.com/golang-jwt/jwt v3.2.2 API: VerifyIssuer/VerifyAudience) and v2 (jwt v5 API: GetIssuer/GetAudience), single PR. Update the test fixture createTestResponseToken to use realistic iss/aud so existing happy-path integration tests keep passing. Tests: * TestValidateAppleIDClaims -- table-driven coverage of the helper: wrong-iss, missing-iss, wrong-aud, missing-aud rejection (and audience-as-list match for v2). * TestAppleHandler_LoginHandler_RejectsWrongIssuer -- integration regression test at the handler boundary. Drives the full exchange flow with a token signed by the test JWK but iss = attacker.example.com. With the fix in place the handler returns 403 invalid id_token; if the validateAppleIDClaims call site is reverted the foreign-iss token authenticates (200 with a JWT) and this test fails on the status-code assertion. The unit-level helper test alone wouldn't catch a missing call. prepareAppleOauthTest gains an explicit testIDTokenOverride parameter so the regression test can inject its own iss/aud while existing callers keep their defaults.
34c60a3 to
1ae23be
Compare
|
addressed the v2 godoc nit -- all three references to the now-removed
v1 versions left as-is since v1 still has the helper. force-pushed. |
Summary
After
jwt.ParseWithClaimssucceeded the Apple handler accepted any token Apple had signed, regardless of which Sign-in-with-Apple client it was issued to. Per Apple's spec the relying party MUST verify:iss == "https://appleid.apple.com"aud == ClientIDWe did neither. This lets an attacker-controlled Sign-in-with-Apple client (or any sibling service in the same Apple developer team) substitute its own valid
id_tokenand authenticate as the foreignsub. Theaudcheck (not theisscheck) is what closes the confused-deputy attack: a sibling service holds a token with the real Appleissbut a differentaud, and could replay it here ifaudis not enforced.Change
provider/apple.go,golang-jwt/jwt v3.2.2): addsvalidateAppleIDClaims(claims, expectedAud)helper usingVerifyIssuer/VerifyAudience, called immediately afterParseWithClaims. Returns403 invalid id_tokenon rejection.v2/provider/apple.go,jwt v5): no helper -- the same checks are wired directly intoParseWithClaimsviajwt.WithIssuer(appleIDTokenIssuer)andjwt.WithAudience(ah.conf.ClientID)parser options. Theerrors.Is(err, jwt.ErrTokenInvalidIssuer | ErrTokenInvalidAudience)branch maps a confused-deputy reject to403 invalid id_token; key/sig parse failures still go to500as before.Both modules in single PR.
Test
TestValidateAppleIDClaims(v1 only) -- table-driven helper coverage: wrong-iss, missing-iss, wrong-aud, missing-aud, audience-as-list match. v2 dropped the helper alongside its unit table -- the iss/aud cases are now jwt v5 library responsibility, exercised end-to-end by the integration tests below.TestAppleHandler_LoginHandler_RejectsWrongIssuer(v1 + v2) -- integration regression test. Drives the full LoginHandler exchange with a token signed by the test JWK but carryingiss = "https://attacker.example.com". With the fix the handler returns403 invalid id_token; without, the foreign-iss token authenticates and this test fails on the status-code assertion.TestAppleHandler_LoginHandler_RejectsWrongAudience(v1 + v2) -- symmetric to the iss test, drives the flow with a token whose iss is the real Apple issuer but whose aud belongs to a different relying party. This is the actual confused-deputy shape; verified to fail when the aud check is removed.prepareAppleOauthTestgains an explicittestIDTokenOverrideparameter so the integration tests can injectiss/audwhile existing callers keep defaults.go test -race ./...green in both modules.