diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 35566b3a..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(go test:*)", - "Bash(docker exec:*)", - "Bash(go build:*)", - "Bash(docker rm:*)", - "Bash(docker run:*)", - "WebSearch", - "Bash(docker:*)", - "Bash(go version)", - "Bash(go mod:*)", - "Bash(go clean:*)", - "Bash(git -C /Users/lakhansamani/personal/authorizer/authorizer/.claude/worktrees/agent-aa2ea6cf branch -a)", - "Bash(WT=/Users/lakhansamani/personal/authorizer/authorizer/.claude/worktrees/agent-aa2ea6cf ls $WT/internal/storage/db/sql/ $WT/internal/storage/db/mongodb/ $WT/internal/storage/db/arangodb/ $WT/internal/storage/db/cassandradb/ $WT/internal/storage/db/dynamodb/ $WT/internal/storage/db/couchbase/)", - "Bash(grep -v \"^$\")", - "Bash(git checkout:*)", - "Bash(git add:*)", - "Bash(cd:*)", - "Bash(git worktree:*)", - "Bash(git branch:*)", - "Bash(git push:*)", - "Bash(git commit:*)", - "Bash(grep \"\\\\.go$\")", - "Bash(ln:*)", - "Bash(chmod:*)", - "Bash(grep -n \"DbType\\\\|TEST_DBS\" /Users/lakhansamani/personal/authorizer/authorizer/internal/constants/*.go)", - "Bash(grep -l \"getTestConfig\" internal/integration_tests/*.go)", - "Bash(grep -c \"getTestConfig\" internal/integration_tests/*.go)", - "Bash(TEST_DBS=\"sqlite\" go test -p 1 -v -run TestSignup ./internal/integration_tests/)", - "Bash(TEST_DBS=\"sqlite\" go test -p 1 -v -run TestDBSelection ./internal/integration_tests/)", - "Bash(TEST_DBS=\"sqlite\" go test -p 1 -v -run TestStorageProvider ./internal/storage/)", - "Bash(find /Users/lakhansamani/personal/authorizer/authorizer -type f -name *audit*)", - "Bash(find /Users/lakhansamani/personal/authorizer/authorizer/internal/graphql -type f -name *.go)", - "Bash(find /Users/lakhansamani/personal/authorizer/authorizer/internal/http_handlers -type f -name *.go)", - "Bash(for db:*)", - "Bash(do echo:*)", - "Read(//Users/lakhansamani/personal/authorizer/authorizer/internal/storage/db/**)", - "Bash(done)", - "Bash(grep:*)", - "Bash(TEST_DBS=\"sqlite\" go test -p 1 -v -count=1 ./internal/integration_tests/)", - "Bash(TEST_DBS=\"sqlite\" go test -p 1 -v -count=1 ./internal/memory_store/...)", - "Bash(TEST_DBS=\"sqlite\" go test -p 1 -v -count=1 ./internal/storage/)" - ] - } -} diff --git a/Makefile b/Makefile index 5d587130..e7b6c0d1 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,52 @@ trivy-scan: clean: rm -rf build dev: - go run main.go --database-type=sqlite --database-url=test.db --jwt-type=HS256 --jwt-secret=test --admin-secret=admin --client-id=123456 --client-secret=secret + @PRIVATE_KEY=$$(printf '%s\n' \ + "-----BEGIN RSA PRIVATE KEY-----" \ + "MIIEowIBAAKCAQEA5dC50fVvQIDm66bBYW+qI+MypP9Pv9SMoHIz9cpcOj9sNhXI" \ + "HTllAM5dhi/+HIaJdPugVQt1rlTJVSFR+ynSmwa89RPHs0o7CBytskGaaf2RJ6zD" \ + "AY3TXKQQVAT3Qvb6ZOQh3+Hh8EOguqdE2iORo9s0KMk7tqS+/y4H3qrC6ngyt2QT" \ + "6VqWbs92N/aO0p/FaL/Q7rGZ+9hTlu3L/T70r3nyeA636kM48XUSqcjDrs4/E+Vx" \ + "XL2Y9Wo4kuaDmMPvMkdl6/wGOwAuIuHpmdfGh0hyLMdgpMqvFyEHuagCy+yFV6ES" \ + "gVi2rOp1g28iISbjpMTkNikbCBuL/TeaSdmEPwIDAQABAoIBABDpeCXmI2Ps+HwN" \ + "VKbNzQirZA3gsfw3xoovd/kVsA14nwcojtuZCStILyQRrRK+qH1A39l8/ecwhckW" \ + "KiPLE0dloAstrkut4e6PZGLn/AE3xUeqVDFtlUkjKQZzKm+1oCiY4eX0B/335BXy" \ + "+u34ocjxTS3Wh+aWyhgaSZROdF3vtcH0PHDroDd8oT/H0xN8fX/T1JozLN3jbxXz" \ + "G4KznKNmx74SrV/y6/wmmQqIsghJBNRZGbg5bn/xcExkcRhWQ3Eka8yAlu31XBQV" \ + "G5AtHEVO8lEi+a2PCA7t1xquw/8b48lc226aaN0pjaySNtyLB7EaKUxBDB2aGx3s" \ + "nVIuOsUCgYEA5xWJ67BKy6MyHphhltTLvre/AGXKDhoV3IR3Hm1cu/iXCT/oOFje" \ + "SYAAqqHXKEB+HZ4xKk7PnBzXkyLXqkCbEIBsv+GZpmboqZrPMfRgS6QcPWWMV9pZ" \ + "f1h68hWpXfyY+yEhAvPKFFhjAt0f5uMiRaAUskZJTFiRJqxz+z0AdD0CgYEA/pgq" \ + "Tk8WMKJBTic3m224FrR4qDok8tQp8FCerS7T5fFnSXZx64ucREn+Wm9ym5UTtbQz" \ + "pne4diIsNIIlVFOjOV7jHjzBS5oly2N/2AT5+ST7O4GZfh/I9VKQWuIh+i3p3s9x" \ + "7PSIlaql9ddV582gCMiL7/QDkHDFBVEz+vq31isCgYB2UoQFZ4ZU0OI34kSN67XL" \ + "mOA2/ue/4sFw4W7w6ISERxxnAw8P0wk2z1EIDchSdvtchQSdqi8Ju4bycvPE3EHJ" \ + "6EhG0+hN2QGm3nrbFEs+T/CZy2ZaEZaj6xVA4bCQTGe0ptj1XwkI89z2uWy9V23U" \ + "Asy2H+EmM29XQxQ7/5c87QKBgQCUO+i1+5pB6tb3OCJKXxHGNoHiASiuMhXRFD+v" \ + "OgqqYWnv/gTKTllH8YUlBqrGJ4B4VVmVXTOLpM30LKqrdJ8esj6uxlUNPc0vpNk0" \ + "34DkLUISHZ1PMBaDr/TY1b1OuxjmYAZHHwG/ksJaZ2xfMPwy4QGJTpwcp2wvcl4/" \ + "jWcoTQKBgALNq5XD/ufvZO2YQq9phF0EQza9zr45zSENF0cOsyVcEG4wfFv4Sg03" \ + "JjTG57oYDCWeLrFCRQpysFi1pDUUCQ1Z/Kf9xKZ/OoE1mXGCKGilBGUijQasuO5Q" \ + "GU+S3Xlk6TWCb2jTgc9UTjlp1FOgQSad4M6TW8vXGkSMODEj5g0S" \ + "-----END RSA PRIVATE KEY-----") && \ + PUBLIC_KEY=$$(printf '%s\n' \ + "-----BEGIN RSA PUBLIC KEY-----" \ + "MIIBCgKCAQEA5dC50fVvQIDm66bBYW+qI+MypP9Pv9SMoHIz9cpcOj9sNhXIHTll" \ + "AM5dhi/+HIaJdPugVQt1rlTJVSFR+ynSmwa89RPHs0o7CBytskGaaf2RJ6zDAY3T" \ + "XKQQVAT3Qvb6ZOQh3+Hh8EOguqdE2iORo9s0KMk7tqS+/y4H3qrC6ngyt2QT6VqW" \ + "bs92N/aO0p/FaL/Q7rGZ+9hTlu3L/T70r3nyeA636kM48XUSqcjDrs4/E+VxXL2Y" \ + "9Wo4kuaDmMPvMkdl6/wGOwAuIuHpmdfGh0hyLMdgpMqvFyEHuagCy+yFV6ESgVi2" \ + "rOp1g28iISbjpMTkNikbCBuL/TeaSdmEPwIDAQAB" \ + "-----END RSA PUBLIC KEY-----") && \ + go run main.go \ + --database-type=sqlite \ + --database-url=test.db \ + --jwt-type=RS256 \ + --jwt-private-key="$$PRIVATE_KEY" \ + --jwt-public-key="$$PUBLIC_KEY" \ + --admin-secret=admin \ + --client-id=kbyuFDidLLm280LIwVFiazOqjO3ty8KH \ + --client-secret=60Op4HFM0I8ajz0WdiStAbziZ-VFQttXuxixHHs2R7r7-CW8GR79l-mmLqMhc-Sa test: go clean --testcache && TEST_DBS="sqlite" $(GO_TEST_ALL) diff --git a/docs/oauth2-oidc-endpoints.md b/docs/oauth2-oidc-endpoints.md index b9795e00..df5aff61 100644 --- a/docs/oauth2-oidc-endpoints.md +++ b/docs/oauth2-oidc-endpoints.md @@ -69,8 +69,8 @@ Initiates the OAuth 2.0 authorization flow. Supports Authorization Code (with PK | `redirect_uri` | No | Where to redirect after auth (defaults to `/app`) | | `scope` | No | Space-separated scopes (default: `openid profile email`) | | `response_mode` | No | `query`, `fragment`, `form_post`, or `web_message` | -| `code_challenge` | Required for `code` | PKCE S256 challenge: `BASE64URL(SHA256(code_verifier))` | -| `code_challenge_method` | No | Only `S256` is supported (defaults to `S256`) | +| `code_challenge` | Recommended | PKCE challenge. Required for public clients; confidential clients may use `client_secret` instead | +| `code_challenge_method` | No | `S256` (default) or `plain` per RFC 7636 | | `nonce` | Recommended | Binds ID token to session; REQUIRED for implicit flows per OIDC | | `screen_hint` | No | Set to `signup` to show the signup page | @@ -392,7 +392,7 @@ grant_type=authorization_code&code=AUTH_CODE&code_verifier=CODE_VERIFIER&client_ | Standard | Status | Notes | |----------|--------|-------| | RFC 6749 (OAuth 2.0) | Implemented | Authorization Code + Refresh Token grants | -| RFC 7636 (PKCE) | Implemented | S256 method required | +| RFC 7636 (PKCE) | Implemented | S256 (default) and plain methods; optional for confidential clients | | RFC 7009 (Token Revocation) | Implemented | Returns 200 for invalid tokens | | RFC 6750 (Bearer Token) | Implemented | WWW-Authenticate on 401 | | OIDC Core 1.0 | Implemented | ID tokens, UserInfo, nonce | diff --git a/internal/graphql/login.go b/internal/graphql/login.go index 13d0f87f..e6ad633d 100644 --- a/internal/graphql/login.go +++ b/internal/graphql/login.go @@ -376,6 +376,8 @@ func (g *graphqlProvider) Login(ctx context.Context, params *model.LoginRequest) code := "" codeChallenge := "" nonce := "" + oidcNonce := "" + authorizeRedirectURI := "" if params.State != nil { // Get state from store authorizeState, _ := g.MemoryStoreProvider.GetState(refs.StringValue(params.State)) @@ -384,10 +386,17 @@ func (g *graphqlProvider) Login(ctx context.Context, params *model.LoginRequest) if len(authorizeStateSplit) > 1 { code = authorizeStateSplit[0] codeChallenge = authorizeStateSplit[1] + if len(authorizeStateSplit) > 2 { + oidcNonce = authorizeStateSplit[2] + } + // RFC 6749 §4.1.3: redirect_uri from /authorize for validation at /oauth/token + if len(authorizeStateSplit) > 3 { + authorizeRedirectURI = authorizeStateSplit[3] + } } else { nonce = authorizeState } - go g.MemoryStoreProvider.RemoveState(refs.StringValue(params.State)) + g.MemoryStoreProvider.RemoveState(refs.StringValue(params.State)) } } @@ -395,12 +404,12 @@ func (g *graphqlProvider) Login(ctx context.Context, params *model.LoginRequest) nonce = uuid.New().String() } hostname := parsers.GetHost(gc) - // gc, user, roles, scope, constants.AuthRecipeMethodBasicAuth, nonce, code authToken, err := g.TokenProvider.CreateAuthToken(gc, &token.AuthTokenConfig{ User: user, Roles: roles, Scope: scope, Nonce: nonce, + OIDCNonce: oidcNonce, Code: code, LoginMethod: constants.AuthRecipeMethodBasicAuth, HostName: hostname, @@ -410,10 +419,9 @@ func (g *graphqlProvider) Login(ctx context.Context, params *model.LoginRequest) return nil, err } - // TODO add to other login options as well // Code challenge could be optional if PKCE flow is not used if code != "" { - if err := g.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+authToken.FingerPrintHash); err != nil { + if err := g.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+authToken.FingerPrintHash+"@@"+oidcNonce+"@@"+authorizeRedirectURI); err != nil { log.Debug().Msg("Failed to set state") return nil, err } diff --git a/internal/graphql/signup.go b/internal/graphql/signup.go index 1bfd4ecc..c8da4dea 100644 --- a/internal/graphql/signup.go +++ b/internal/graphql/signup.go @@ -287,6 +287,8 @@ func (g *graphqlProvider) SignUp(ctx context.Context, params *model.SignUpReques code := "" codeChallenge := "" nonce := "" + oidcNonce := "" + authorizeRedirectURI := "" if params.State != nil { // Get state from store authorizeState, _ := g.MemoryStoreProvider.GetState(refs.StringValue(params.State)) @@ -295,10 +297,16 @@ func (g *graphqlProvider) SignUp(ctx context.Context, params *model.SignUpReques if len(authorizeStateSplit) > 1 { code = authorizeStateSplit[0] codeChallenge = authorizeStateSplit[1] + if len(authorizeStateSplit) > 2 { + oidcNonce = authorizeStateSplit[2] + } + if len(authorizeStateSplit) > 3 { + authorizeRedirectURI = authorizeStateSplit[3] + } } else { nonce = authorizeState } - go g.MemoryStoreProvider.RemoveState(refs.StringValue(params.State)) + g.MemoryStoreProvider.RemoveState(refs.StringValue(params.State)) } } @@ -310,6 +318,7 @@ func (g *graphqlProvider) SignUp(ctx context.Context, params *model.SignUpReques Roles: roles, Scope: scope, Nonce: nonce, + OIDCNonce: oidcNonce, Code: code, LoginMethod: constants.AuthRecipeMethodBasicAuth, HostName: hostname, @@ -321,7 +330,7 @@ func (g *graphqlProvider) SignUp(ctx context.Context, params *model.SignUpReques // Code challenge could be optional if PKCE flow is not used if code != "" { - if err := g.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+authToken.FingerPrintHash); err != nil { + if err := g.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+authToken.FingerPrintHash+"@@"+oidcNonce+"@@"+authorizeRedirectURI); err != nil { log.Debug().Err(err).Msg("SetState failed") return nil, err } diff --git a/internal/graphql/verify_otp.go b/internal/graphql/verify_otp.go index 60a35463..9ba782e9 100644 --- a/internal/graphql/verify_otp.go +++ b/internal/graphql/verify_otp.go @@ -156,6 +156,8 @@ func (g *graphqlProvider) VerifyOTP(ctx context.Context, params *model.VerifyOTP code := "" codeChallenge := "" nonce := "" + oidcNonce := "" + authorizeRedirectURI := "" if params.State != nil { // Get state from store authorizeState, _ := g.MemoryStoreProvider.GetState(refs.StringValue(params.State)) @@ -164,23 +166,29 @@ func (g *graphqlProvider) VerifyOTP(ctx context.Context, params *model.VerifyOTP if len(authorizeStateSplit) > 1 { code = authorizeStateSplit[0] codeChallenge = authorizeStateSplit[1] + if len(authorizeStateSplit) > 2 { + oidcNonce = authorizeStateSplit[2] + } + if len(authorizeStateSplit) > 3 { + authorizeRedirectURI = authorizeStateSplit[3] + } } else { nonce = authorizeState } - go g.MemoryStoreProvider.RemoveState(refs.StringValue(params.State)) + g.MemoryStoreProvider.RemoveState(refs.StringValue(params.State)) } } if nonce == "" { nonce = uuid.New().String() } hostname := parsers.GetHost(gc) - // user, roles, scope, loginMethod, nonce, code authToken, err := g.TokenProvider.CreateAuthToken(gc, &token.AuthTokenConfig{ User: user, Roles: roles, Scope: scope, LoginMethod: loginMethod, Nonce: nonce, + OIDCNonce: oidcNonce, Code: code, HostName: hostname, }) @@ -191,7 +199,7 @@ func (g *graphqlProvider) VerifyOTP(ctx context.Context, params *model.VerifyOTP // Code challenge could be optional if PKCE flow is not used if code != "" { - if err := g.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+authToken.FingerPrintHash); err != nil { + if err := g.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+authToken.FingerPrintHash+"@@"+oidcNonce+"@@"+authorizeRedirectURI); err != nil { log.Debug().Err(err).Msg("Failed to set state") return nil, err } diff --git a/internal/http_handlers/authorize.go b/internal/http_handlers/authorize.go index 9a6d03d1..b20e4ff5 100644 --- a/internal/http_handlers/authorize.go +++ b/internal/http_handlers/authorize.go @@ -155,10 +155,11 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc { if codeChallengeMethod == "" && codeChallenge != "" { codeChallengeMethod = "S256" } - if codeChallengeMethod != "" && codeChallengeMethod != "S256" { + // RFC 7636 §4.2: servers MUST support plain and S256 + if codeChallengeMethod != "" && codeChallengeMethod != "S256" && codeChallengeMethod != "plain" { gc.JSON(http.StatusBadRequest, gin.H{ "error": "invalid_request", - "error_description": "Only S256 code_challenge_method is supported", + "error_description": "Supported code_challenge_method values are S256 and plain", }) return } @@ -174,17 +175,24 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc { } responseType = canonical - // OIDC Core §3.3 hybrid response_type combinations. + // OIDC Core §3.3 hybrid response_type combinations (contain "code" plus tokens). isHybrid := responseType == "code id_token" || responseType == "code token" || - responseType == "code id_token token" || + responseType == "code id_token token" + + // Implicit flows: tokens returned directly, no code exchange. + // "id_token token" is implicit per OIDC Core §3.2, NOT hybrid. + isImplicit := responseType == "token" || + responseType == "id_token" || responseType == "id_token token" - if isHybrid { - // OIDC Core §3.3.2.5: hybrid flow MUST NOT use query response_mode. + + if isHybrid || isImplicit { + // Tokens MUST NOT appear in query strings (OAuth 2.0 Multiple + // Response Type Encoding Practices §3.0). if rawResponseMode == constants.ResponseModeQuery { gc.JSON(http.StatusBadRequest, gin.H{ "error": "invalid_request", - "error_description": "response_mode=query is not allowed for hybrid response_type", + "error_description": "response_mode=query is not allowed for response_type=" + responseType, }) return } @@ -195,22 +203,39 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc { } } - if err := h.validateAuthorizeRequest(responseType, responseMode, clientID, state, codeChallenge); err != nil { + if err := h.validateAuthorizeRequest(responseType, responseMode, clientID, state); err != nil { log.Debug().Err(err).Msg("Invalid request") gc.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - code := uuid.New().String() + // OIDC Core §3.2.2.1 / §3.3.2.1: nonce is REQUIRED when id_token + // appears in the response_type (implicit and hybrid flows that + // return id_token directly from the authorization endpoint). + requiresNonce := strings.Contains(responseType, "id_token") + if requiresNonce && nonce == "" { + gc.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_request", + "error_description": "nonce is required for response_type=" + responseType, + }) + return + } + + // Generate code only for flows that include "code" in response_type. + hasCodeFlow := strings.Contains(responseType, "code") + code := "" + if hasCodeFlow { + code = uuid.New().String() + } if nonce == "" { nonce = uuid.New().String() } - log = log.With().Str("response_type", responseType).Str("response_mode", responseMode).Str("state", state).Str("code_challenge", codeChallenge).Str("scope", scopeString).Str("client_id", clientID).Str("nonce", nonce).Logger() + log = log.With().Str("response_type", responseType).Str("response_mode", responseMode).Str("scope", scopeString).Str("client_id", clientID).Bool("has_code_challenge", codeChallenge != "").Logger() // TODO add state with timeout // used for response mode query or fragment - authState := "state=" + state + "&scope=" + scopeString + "&redirect_uri=" + redirectURI + authState := "state=" + state + "&scope=" + scopeString + "&redirect_uri=" + redirectURI + "&response_mode=" + responseMode + "&response_type=" + url.QueryEscape(responseType) // OIDC Core §3.1.2.1: login_hint and ui_locales are forwarded // to the login UI so it can pre-fill the email field and pick // the UI language. @@ -220,9 +245,15 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc { if uiLocales != "" { authState += "&ui_locales=" + url.QueryEscape(uiLocales) } - if responseType == constants.ResponseTypeCode { + if hasCodeFlow { authState += "&code=" + code - if err := h.MemoryStoreProvider.SetState(state, code+"@@"+codeChallenge); err != nil { + // Store code_challenge with method so token endpoint can verify. + // Format: "challenge::method@@session" or "@@session" (no PKCE). + challengeData := codeChallenge + if codeChallenge != "" { + challengeData = codeChallenge + "::" + codeChallengeMethod + } + if err := h.MemoryStoreProvider.SetState(state, code+"@@"+challengeData+"@@"+nonce+"@@"+url.QueryEscape(redirectURI)); err != nil { log.Debug().Err(err).Msg("Error setting temp code") gc.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) return @@ -248,12 +279,13 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc { authURL = baseAppPath + "#" + authState } - if responseType == constants.ResponseTypeCode && codeChallenge == "" { + // Reject if code_challenge_method is set without code_challenge + if responseType == constants.ResponseTypeCode && codeChallenge == "" && codeChallengeMethod != "" { handleResponse(gc, responseMode, authURL, redirectURI, map[string]interface{}{ "type": "authorization_response", "response": map[string]interface{}{ - "error": "code_challenge_required", - "error_description": "code challenge is required", + "error": "invalid_request", + "error_description": "code_challenge is required when code_challenge_method is specified", }, }, http.StatusOK) return @@ -333,6 +365,7 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc { "authorization_response": errData, }) case constants.ResponseModeFormPost: + setFormPostCSP(gc, redirectURI) gc.HTML(http.StatusOK, authorizeFormPostTemplate, gin.H{ "target_origin": redirectURI, "authorization_response": errData["response"], @@ -414,7 +447,11 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc { // OIDC Core §3.3: hybrid flow codes are exchanged at /oauth/token // which calls ValidateBrowserSession — store AES-encrypted session // (FingerPrintHash), not the raw nonce (FingerPrint). - if err := h.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+authToken.FingerPrintHash); err != nil { + hybridChallengeData := codeChallenge + if codeChallenge != "" { + hybridChallengeData = codeChallenge + "::" + codeChallengeMethod + } + if err := h.MemoryStoreProvider.SetState(code, hybridChallengeData+"@@"+authToken.FingerPrintHash+"@@"+nonce+"@@"+url.QueryEscape(redirectURI)); err != nil { log.Debug().Err(err).Msg("Error setting temp code for hybrid") handleResponse(gc, responseMode, authURL, redirectURI, loginError, http.StatusOK) return @@ -436,52 +473,99 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc { } hasAccessToken := responseType == "code token" || - responseType == "code id_token token" || - responseType == "id_token token" + responseType == "code id_token token" hasIDToken := responseType == "code id_token" || - responseType == "code id_token token" || - responseType == "id_token token" + responseType == "code id_token token" - // Build the response map. Always include code + state for - // hybrid combos containing "code"; include id_token / - // access_token based on the requested set. + // Build the response map. Hybrid always includes code + state. res := map[string]interface{}{ + "code": code, "state": state, "token_type": "Bearer", - "scope": strings.Join(scope, " "), "expires_in": expiresIn, } - hasCode := strings.Contains(responseType, "code") - if hasCode { - res["code"] = code - } if hasAccessToken { res["access_token"] = authToken.AccessToken.Token } if hasIDToken { res["id_token"] = authToken.IDToken.Token } - if nonce != "" { - res["nonce"] = nonce - } // Build the fragment params string for redirect modes. - params := "state=" + state + "&token_type=bearer&expires_in=" + strconv.FormatInt(expiresIn, 10) - if hasCode { - params += "&code=" + code - } + params := "code=" + code + "&state=" + state + "&token_type=bearer&expires_in=" + strconv.FormatInt(expiresIn, 10) if hasAccessToken { params += "&access_token=" + authToken.AccessToken.Token } if hasIDToken { params += "&id_token=" + authToken.IDToken.Token } - if nonce != "" { - params += "&nonce=" + nonce + + // Hybrid defaults to fragment; the pre-check above rejected query. + if responseMode == constants.ResponseModeFragment { + if strings.Contains(redirectURI, "#") { + redirectURI = redirectURI + "&" + params + } else { + redirectURI = redirectURI + "#" + params + } + } + + handleResponse(gc, responseMode, authURL, redirectURI, map[string]interface{}{ + "type": "authorization_response", + "response": res, + }, http.StatusOK) + return + } + + // OIDC Core §3.2.2.5: response_type="id_token token" is an implicit + // flow returning both id_token and access_token directly. No code, no + // refresh_token. Nonce is required (enforced above). + if responseType == "id_token token" { + hostname := parsers.GetHost(gc) + authToken, err := h.TokenProvider.CreateAuthToken(gc, &token.AuthTokenConfig{ + User: user, + Nonce: nonce, + Roles: claims.Roles, + Scope: scope, + LoginMethod: claims.LoginMethod, + HostName: hostname, + }) + if err != nil { + log.Debug().Err(err).Msg("Error creating auth token for id_token token response") + handleResponse(gc, responseMode, authURL, redirectURI, loginError, http.StatusOK) + return + } + + if err := h.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeSessionToken+"_"+authToken.FingerPrint, authToken.FingerPrintHash, authToken.SessionTokenExpiresAt); err != nil { + log.Debug().Err(err).Msg("Error persisting session for id_token token") + handleResponse(gc, responseMode, authURL, redirectURI, loginError, http.StatusOK) + return + } + if err := h.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeAccessToken+"_"+authToken.FingerPrint, authToken.AccessToken.Token, authToken.AccessToken.ExpiresAt); err != nil { + log.Debug().Err(err).Msg("Error persisting access token for id_token token") + handleResponse(gc, responseMode, authURL, redirectURI, loginError, http.StatusOK) + return + } + cookie.SetSession(gc, authToken.FingerPrintHash, h.Config.AppCookieSecure) + + expiresIn := authToken.AccessToken.ExpiresAt - time.Now().Unix() + if expiresIn <= 0 { + expiresIn = 1 + } + + res := map[string]interface{}{ + "access_token": authToken.AccessToken.Token, + "id_token": authToken.IDToken.Token, + "state": state, + "token_type": "Bearer", + "expires_in": expiresIn, } - // Hybrid is fragment-default; the pre-check above ensured - // responseMode != "query". + params := "access_token=" + authToken.AccessToken.Token + + "&id_token=" + authToken.IDToken.Token + + "&token_type=bearer&expires_in=" + strconv.FormatInt(expiresIn, 10) + + "&state=" + state + + // Fragment-only: tokens MUST NOT appear in query strings. if responseMode == constants.ResponseModeFragment { if strings.Contains(redirectURI, "#") { redirectURI = redirectURI + "&" + params @@ -519,7 +603,11 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc { // } // TODO: add state with timeout - if err := h.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+newSessionToken); err != nil { + codeChallengeData := codeChallenge + if codeChallenge != "" { + codeChallengeData = codeChallenge + "::" + codeChallengeMethod + } + if err := h.MemoryStoreProvider.SetState(code, codeChallengeData+"@@"+newSessionToken+"@@"+nonce+"@@"+url.QueryEscape(redirectURI)); err != nil { log.Debug().Err(err).Msg("Error setting temp code") handleResponse(gc, responseMode, authURL, redirectURI, loginError, http.StatusOK) return @@ -573,9 +661,61 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc { return } - if responseType == constants.ResponseTypeToken || responseType == constants.ResponseTypeIDToken { + // OIDC Core §3.2.2.5: response_type=id_token returns ONLY id_token + // and state. No access_token, token_type, or expires_in. + if responseType == constants.ResponseTypeIDToken { + hostname := parsers.GetHost(gc) + authToken, err := h.TokenProvider.CreateAuthToken(gc, &token.AuthTokenConfig{ + User: user, + Nonce: nonce, + Roles: claims.Roles, + Scope: scope, + LoginMethod: claims.LoginMethod, + HostName: hostname, + }) + if err != nil { + log.Debug().Err(err).Msg("Error creating auth token") + handleResponse(gc, responseMode, authURL, redirectURI, loginError, http.StatusOK) + return + } + + if err := h.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeSessionToken+"_"+nonce, authToken.FingerPrintHash, authToken.SessionTokenExpiresAt); err != nil { + log.Debug().Err(err).Msg("Error setting session token") + handleResponse(gc, responseMode, authURL, redirectURI, loginError, http.StatusOK) + return + } + + cookie.SetSession(gc, authToken.FingerPrintHash, h.Config.AppCookieSecure) + + // OIDC Core §3.2.2.5: response params are id_token + state only. + // The nonce is carried inside the id_token JWT claims (not as a + // separate response parameter). + res := map[string]interface{}{ + "id_token": authToken.IDToken.Token, + "state": state, + } + params := "id_token=" + authToken.IDToken.Token + "&state=" + state + + if responseMode == constants.ResponseModeFragment { + if strings.Contains(redirectURI, "#") { + redirectURI = redirectURI + "&" + params + } else { + redirectURI = redirectURI + "#" + params + } + } + + handleResponse(gc, responseMode, authURL, redirectURI, map[string]interface{}{ + "type": "authorization_response", + "response": res, + }, http.StatusOK) + return + } + + // RFC 6749 §4.2.2: response_type=token is a pure OAuth 2.0 implicit + // flow. Return ONLY access_token, token_type, expires_in, state. + // No id_token (not OIDC). No refresh_token (implicit MUST NOT). + if responseType == constants.ResponseTypeToken { hostname := parsers.GetHost(gc) - // rollover the session for security authToken, err := h.TokenProvider.CreateAuthToken(gc, &token.AuthTokenConfig{ User: user, Nonce: nonce, @@ -604,46 +744,25 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc { cookie.SetSession(gc, authToken.FingerPrintHash, h.Config.AppCookieSecure) - // OAuth 2.0: expires_in is lifetime in seconds (RFC 6749 §4.2.2), not an absolute timestamp. expiresIn := authToken.AccessToken.ExpiresAt - time.Now().Unix() if expiresIn <= 0 { expiresIn = 1 } - // used of query mode - params := "access_token=" + authToken.AccessToken.Token + "&token_type=bearer&expires_in=" + strconv.FormatInt(expiresIn, 10) + "&state=" + state + "&id_token=" + authToken.IDToken.Token + // RFC 6749 §4.2.2: implicit token response params. + params := "access_token=" + authToken.AccessToken.Token + + "&token_type=bearer&expires_in=" + strconv.FormatInt(expiresIn, 10) + + "&state=" + state res := map[string]interface{}{ "access_token": authToken.AccessToken.Token, - "id_token": authToken.IDToken.Token, "state": state, - "scope": strings.Join(scope, " "), "token_type": "Bearer", "expires_in": expiresIn, } - if nonce != "" { - params += "&nonce=" + nonce - res["nonce"] = nonce - } - - if authToken.RefreshToken != nil { - res["refresh_token"] = authToken.RefreshToken.Token - params += "&refresh_token=" + authToken.RefreshToken.Token - if err := h.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeRefreshToken+"_"+authToken.FingerPrint, authToken.RefreshToken.Token, authToken.RefreshToken.ExpiresAt); err != nil { - log.Debug().Err(err).Msg("Error setting refresh token") - handleResponse(gc, responseMode, authURL, redirectURI, loginError, http.StatusOK) - return - } - } - - if responseMode == constants.ResponseModeQuery { - if strings.Contains(redirectURI, "?") { - redirectURI = redirectURI + "&" + params - } else { - redirectURI = redirectURI + "?" + params - } - } else if responseMode == constants.ResponseModeFragment { + // Fragment-only: tokens MUST NOT appear in query strings. + if responseMode == constants.ResponseModeFragment { if strings.Contains(redirectURI, "#") { redirectURI = redirectURI + "&" + params } else { @@ -704,7 +823,7 @@ func supportedResponseTypeSet(raw string) (string, bool) { return "", false } -func (h *httpProvider) validateAuthorizeRequest(responseType, responseMode, clientID, state, codeChallenge string) error { +func (h *httpProvider) validateAuthorizeRequest(responseType, responseMode, clientID, state string) error { if strings.TrimSpace(state) == "" { return fmt.Errorf("invalid state. state is required to prevent csrf attack") } @@ -756,6 +875,27 @@ func (h *httpProvider) parseIDTokenHintSubject(idTokenHint string) string { return sub } +// setFormPostCSP overrides the Content-Security-Policy header to allow +// form-action to the redirect_uri origin. The default CSP sets +// form-action 'self' which blocks cross-origin form POSTs required by +// OIDC Form Post Response Mode (OAuth 2.0 Form Post Response Mode §1). +func setFormPostCSP(gc *gin.Context, redirectURI string) { + origin := "'self'" + if u, err := url.Parse(redirectURI); err == nil && u.Host != "" { + origin = "'self' " + u.Scheme + "://" + u.Host + } + gc.Writer.Header().Set("Content-Security-Policy", + "default-src 'self'; "+ + "script-src 'self' 'unsafe-inline'; "+ + "style-src 'self' 'unsafe-inline'; "+ + "img-src 'self' data: https:; "+ + "font-src 'self' data:; "+ + "connect-src 'self'; "+ + "frame-ancestors 'none'; "+ + "base-uri 'self'; "+ + "form-action "+origin) +} + func handleResponse(gc *gin.Context, responseMode, authURI, redirectURI string, data map[string]interface{}, httpStatusCode int) { isAuthenticationRequired := false if resp, ok := data["response"].(map[string]interface{}); ok { @@ -780,6 +920,7 @@ func handleResponse(gc *gin.Context, responseMode, authURI, redirectURI string, }) return case constants.ResponseModeFormPost: + setFormPostCSP(gc, redirectURI) gc.HTML(httpStatusCode, authorizeFormPostTemplate, gin.H{ "target_origin": redirectURI, "authorization_response": data["response"], diff --git a/internal/http_handlers/oauth_authorize_state.go b/internal/http_handlers/oauth_authorize_state.go index b86462b0..c1eb0ab0 100644 --- a/internal/http_handlers/oauth_authorize_state.go +++ b/internal/http_handlers/oauth_authorize_state.go @@ -1,28 +1,42 @@ package http_handlers -import "strings" +import ( + "net/url" + "strings" +) // consumeAuthorizeState resolves the OpenID Connect `/authorize` state (stateValue) into either: -// - (code, codeChallenge) for authorization-code + PKCE flows, OR +// - (code, codeChallenge, nonce, redirectURI) for authorization-code + PKCE flows, OR // - (nonce) for implicit/hybrid-style flows. // // It is a best-effort bridge used by the social OAuth callback: // - For standalone social login (`/oauth_login/:provider`) there is no `/authorize` entry, so it returns empty values. // - For OIDC authorize flows, it consumes the entry to keep it single-use. -func (h *httpProvider) consumeAuthorizeState(stateValue string) (code, codeChallenge, nonce string, err error) { +func (h *httpProvider) consumeAuthorizeState(stateValue string) (code, codeChallenge, nonce, redirectURI string, err error) { if stateValue == "" { - return "", "", "", nil + return "", "", "", "", nil } authorizeState, err := h.MemoryStoreProvider.GetState(stateValue) if err != nil || authorizeState == "" { - return "", "", "", err + return "", "", "", "", err } authorizeStateSplit := strings.Split(authorizeState, "@@") if len(authorizeStateSplit) > 1 { code = authorizeStateSplit[0] codeChallenge = authorizeStateSplit[1] + // Third part carries the OIDC nonce from the /authorize request. + if len(authorizeStateSplit) > 2 { + nonce = authorizeStateSplit[2] + } + // Fourth part carries the URL-encoded redirect_uri from the /authorize + // request for RFC 6749 §4.1.3 validation at the token endpoint. + // It is URL-encoded to prevent the @@ delimiter from being confused + // with @@ characters that may appear in the redirect_uri. + if len(authorizeStateSplit) > 3 { + redirectURI, _ = url.QueryUnescape(authorizeStateSplit[3]) + } } else { nonce = authorizeState } @@ -30,6 +44,5 @@ func (h *httpProvider) consumeAuthorizeState(stateValue string) (code, codeChall // Consume authorize state; it should be single-use. _ = h.MemoryStoreProvider.RemoveState(stateValue) - return code, codeChallenge, nonce, nil + return code, codeChallenge, nonce, redirectURI, nil } - diff --git a/internal/http_handlers/oauth_authorize_state_test.go b/internal/http_handlers/oauth_authorize_state_test.go index 6a14b1a3..9088b1a8 100644 --- a/internal/http_handlers/oauth_authorize_state_test.go +++ b/internal/http_handlers/oauth_authorize_state_test.go @@ -20,7 +20,7 @@ func TestConsumeAuthorizeState_Nonce(t *testing.T) { h := &httpProvider{ Config: cfg, Dependencies: Dependencies{ - Log: &logger, + Log: &logger, MemoryStoreProvider: ms, }, } @@ -28,11 +28,12 @@ func TestConsumeAuthorizeState_Nonce(t *testing.T) { stateValue := "state-1" require.NoError(t, ms.SetState(stateValue, "nonce-123")) - code, codeChallenge, nonce, err := h.consumeAuthorizeState(stateValue) + code, codeChallenge, nonce, redirectURI, err := h.consumeAuthorizeState(stateValue) require.NoError(t, err) require.Empty(t, code) require.Empty(t, codeChallenge) require.Equal(t, "nonce-123", nonce) + require.Empty(t, redirectURI) // Consumed after, err := ms.GetState(stateValue) @@ -49,7 +50,7 @@ func TestConsumeAuthorizeState_CodeAndPKCE(t *testing.T) { h := &httpProvider{ Config: cfg, Dependencies: Dependencies{ - Log: &logger, + Log: &logger, MemoryStoreProvider: ms, }, } @@ -57,11 +58,12 @@ func TestConsumeAuthorizeState_CodeAndPKCE(t *testing.T) { stateValue := "state-2" require.NoError(t, ms.SetState(stateValue, "code-abc@@challenge-xyz")) - code, codeChallenge, nonce, err := h.consumeAuthorizeState(stateValue) + code, codeChallenge, nonce, redirectURI, err := h.consumeAuthorizeState(stateValue) require.NoError(t, err) require.Equal(t, "code-abc", code) require.Equal(t, "challenge-xyz", codeChallenge) require.Empty(t, nonce) + require.Empty(t, redirectURI) // Consumed after, err := ms.GetState(stateValue) @@ -69,6 +71,95 @@ func TestConsumeAuthorizeState_CodeAndPKCE(t *testing.T) { require.Empty(t, after) } +func TestConsumeAuthorizeState_CodePKCEAndNonce(t *testing.T) { + cfg := &config.Config{} + logger := zerolog.Nop() + ms, err := inmemorystore.NewInMemoryProvider(cfg, &inmemorystore.Dependencies{Log: &logger}) + require.NoError(t, err) + + h := &httpProvider{ + Config: cfg, + Dependencies: Dependencies{ + Log: &logger, + MemoryStoreProvider: ms, + }, + } + + stateValue := "state-3" + require.NoError(t, ms.SetState(stateValue, "code-abc@@challenge-xyz@@oidc-nonce-123")) + + code, codeChallenge, nonce, redirectURI, err := h.consumeAuthorizeState(stateValue) + require.NoError(t, err) + require.Equal(t, "code-abc", code) + require.Equal(t, "challenge-xyz", codeChallenge) + require.Equal(t, "oidc-nonce-123", nonce) + require.Empty(t, redirectURI) + + // Consumed + after, err := ms.GetState(stateValue) + require.NoError(t, err) + require.Empty(t, after) +} + +func TestConsumeAuthorizeState_CodePKCENonceAndRedirectURI(t *testing.T) { + cfg := &config.Config{} + logger := zerolog.Nop() + ms, err := inmemorystore.NewInMemoryProvider(cfg, &inmemorystore.Dependencies{Log: &logger}) + require.NoError(t, err) + + h := &httpProvider{ + Config: cfg, + Dependencies: Dependencies{ + Log: &logger, + MemoryStoreProvider: ms, + }, + } + + // redirect_uri is URL-encoded in the state (as written by authorize.go) + stateValue := "state-4" + require.NoError(t, ms.SetState(stateValue, "code-abc@@challenge-xyz@@oidc-nonce-123@@https%3A%2F%2Fexample.com%2Fcallback")) + + code, codeChallenge, nonce, redirectURI, err := h.consumeAuthorizeState(stateValue) + require.NoError(t, err) + require.Equal(t, "code-abc", code) + require.Equal(t, "challenge-xyz", codeChallenge) + require.Equal(t, "oidc-nonce-123", nonce) + // Returned decoded + require.Equal(t, "https://example.com/callback", redirectURI) + + // Consumed + after, err := ms.GetState(stateValue) + require.NoError(t, err) + require.Empty(t, after) +} + +func TestConsumeAuthorizeState_RedirectURIWithDelimiter(t *testing.T) { + cfg := &config.Config{} + logger := zerolog.Nop() + ms, err := inmemorystore.NewInMemoryProvider(cfg, &inmemorystore.Dependencies{Log: &logger}) + require.NoError(t, err) + + h := &httpProvider{ + Config: cfg, + Dependencies: Dependencies{ + Log: &logger, + MemoryStoreProvider: ms, + }, + } + + // A redirect_uri containing @@ must not corrupt field parsing. + // When properly URL-encoded, the @@ becomes %40%40. + stateValue := "state-5" + require.NoError(t, ms.SetState(stateValue, "code-abc@@challenge-xyz@@nonce-123@@https%3A%2F%2Fevil.com%2F%40%40injected")) + + code, codeChallenge, nonce, redirectURI, err := h.consumeAuthorizeState(stateValue) + require.NoError(t, err) + require.Equal(t, "code-abc", code) + require.Equal(t, "challenge-xyz", codeChallenge) + require.Equal(t, "nonce-123", nonce) + require.Equal(t, "https://evil.com/@@injected", redirectURI) +} + func TestConsumeAuthorizeState_MissingKey_ReturnsEmpty(t *testing.T) { cfg := &config.Config{} logger := zerolog.Nop() @@ -78,16 +169,17 @@ func TestConsumeAuthorizeState_MissingKey_ReturnsEmpty(t *testing.T) { h := &httpProvider{ Config: cfg, Dependencies: Dependencies{ - Log: &logger, + Log: &logger, MemoryStoreProvider: ms, }, } - code, codeChallenge, nonce, err := h.consumeAuthorizeState("does-not-exist") + code, codeChallenge, nonce, redirectURI, err := h.consumeAuthorizeState("does-not-exist") require.NoError(t, err) require.Empty(t, code) require.Empty(t, codeChallenge) require.Empty(t, nonce) + require.Empty(t, redirectURI) } // This models Redis behaviour where missing keys return redis.Nil. @@ -105,7 +197,7 @@ func TestConsumeAuthorizeState_RedisNil_Propagates(t *testing.T) { }, } - _, _, _, err := h.consumeAuthorizeState("missing") + _, _, _, _, err := h.consumeAuthorizeState("missing") require.ErrorIs(t, err, goredis.Nil) } @@ -116,20 +208,21 @@ type fakeMemoryStore struct { removedKeys []string } -func (f *fakeMemoryStore) SetUserSession(userId, key, token string, expiration int64) error { return nil } -func (f *fakeMemoryStore) GetUserSession(userId, key string) (string, error) { return "", nil } -func (f *fakeMemoryStore) DeleteUserSession(userId, key string) error { return nil } -func (f *fakeMemoryStore) DeleteAllUserSessions(userId string) error { return nil } -func (f *fakeMemoryStore) DeleteSessionForNamespace(namespace string) error { return nil } -func (f *fakeMemoryStore) SetMfaSession(userId, key string, expiration int64) error { return nil } -func (f *fakeMemoryStore) GetMfaSession(userId, key string) (string, error) { return "", nil } -func (f *fakeMemoryStore) GetAllMfaSessions(userId string) ([]string, error) { return nil, nil } -func (f *fakeMemoryStore) DeleteMfaSession(userId, key string) error { return nil } -func (f *fakeMemoryStore) SetState(key, state string) error { return nil } -func (f *fakeMemoryStore) GetState(key string) (string, error) { return f.getStateVal, f.getStateErr } +func (f *fakeMemoryStore) SetUserSession(userId, key, token string, expiration int64) error { + return nil +} +func (f *fakeMemoryStore) GetUserSession(userId, key string) (string, error) { return "", nil } +func (f *fakeMemoryStore) DeleteUserSession(userId, key string) error { return nil } +func (f *fakeMemoryStore) DeleteAllUserSessions(userId string) error { return nil } +func (f *fakeMemoryStore) DeleteSessionForNamespace(namespace string) error { return nil } +func (f *fakeMemoryStore) SetMfaSession(userId, key string, expiration int64) error { return nil } +func (f *fakeMemoryStore) GetMfaSession(userId, key string) (string, error) { return "", nil } +func (f *fakeMemoryStore) GetAllMfaSessions(userId string) ([]string, error) { return nil, nil } +func (f *fakeMemoryStore) DeleteMfaSession(userId, key string) error { return nil } +func (f *fakeMemoryStore) SetState(key, state string) error { return nil } +func (f *fakeMemoryStore) GetState(key string) (string, error) { return f.getStateVal, f.getStateErr } func (f *fakeMemoryStore) RemoveState(key string) error { f.removedKeys = append(f.removedKeys, key) return nil } func (f *fakeMemoryStore) GetAllData() (map[string]string, error) { return map[string]string{}, nil } - diff --git a/internal/http_handlers/oauth_callback.go b/internal/http_handlers/oauth_callback.go index 25184649..ebd59750 100644 --- a/internal/http_handlers/oauth_callback.go +++ b/internal/http_handlers/oauth_callback.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" @@ -67,7 +68,7 @@ func (h *httpProvider) OAuthCallbackHandler() gin.HandlerFunc { return } // remove state from store - go h.MemoryStoreProvider.RemoveState(state) + h.MemoryStoreProvider.RemoveState(state) stateValue := sessionSplit[0] redirectURL := sessionSplit[1] inputRoles := strings.Split(sessionSplit[2], ",") @@ -295,7 +296,7 @@ func (h *httpProvider) OAuthCallbackHandler() gin.HandlerFunc { // // In the standalone social login flow (`/oauth_login/:provider`), this entry will not exist and we // simply generate a nonce and continue. - code, codeChallenge, nonce, err := h.consumeAuthorizeState(stateValue) + code, codeChallenge, nonce, authorizeRedirectURI, err := h.consumeAuthorizeState(stateValue) if err != nil && !errors.Is(err, goredis.Nil) { log.Debug().Err(err).Str("state", stateValue).Msg("Failed to get authorize state from store") } @@ -320,7 +321,7 @@ func (h *httpProvider) OAuthCallbackHandler() gin.HandlerFunc { // Code challenge could be optional if PKCE flow is not used if code != "" { - if err := h.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+authToken.FingerPrintHash); err != nil { + if err := h.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+authToken.FingerPrintHash+"@@"+nonce+"@@"+url.QueryEscape(authorizeRedirectURI)); err != nil { log.Debug().Err(err).Msg("Failed to set state") ctx.JSON(500, gin.H{"error": err.Error()}) return @@ -371,7 +372,7 @@ func (h *httpProvider) OAuthCallbackHandler() gin.HandlerFunc { redirectURL = redirectURL + "?" + strings.TrimPrefix(params, "&") } // remove state from store - go h.MemoryStoreProvider.RemoveState(state) + h.MemoryStoreProvider.RemoveState(state) metrics.RecordAuthEvent(metrics.EventOAuthCallback, metrics.StatusSuccess) metrics.ActiveSessions.Inc() h.AuditProvider.LogEvent(audit.Event{ diff --git a/internal/http_handlers/openid_config.go b/internal/http_handlers/openid_config.go index 8868364c..97eeba04 100644 --- a/internal/http_handlers/openid_config.go +++ b/internal/http_handlers/openid_config.go @@ -51,7 +51,7 @@ func (h *httpProvider) OpenIDConfigurationHandler() gin.HandlerFunc { "response_modes_supported": []string{"query", "fragment", "form_post", "web_message"}, "grant_types_supported": grantTypes, "token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"}, - "code_challenge_methods_supported": []string{"S256"}, + "code_challenge_methods_supported": []string{"S256", "plain"}, "revocation_endpoint": issuer + "/oauth/revoke", "revocation_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"}, "introspection_endpoint": issuer + "/oauth/introspect", diff --git a/internal/http_handlers/token.go b/internal/http_handlers/token.go index 40cbb65d..61c75c69 100644 --- a/internal/http_handlers/token.go +++ b/internal/http_handlers/token.go @@ -5,6 +5,7 @@ import ( "crypto/subtle" "encoding/base64" "net/http" + "net/url" "strings" "time" @@ -42,10 +43,10 @@ func (h *httpProvider) TokenHandler() gin.HandlerFunc { var reqBody RequestBody if err := gc.Bind(&reqBody); err != nil { - log.Debug().Err(err).Msg("failed to bind json") + log.Debug().Err(err).Msg("failed to bind request body") gc.JSON(http.StatusBadRequest, gin.H{ - "error": "error_binding_json", - "error_description": err.Error(), + "error": "invalid_request", + "error_description": "Failed to parse request body", }) return } @@ -111,6 +112,7 @@ func (h *httpProvider) TokenHandler() gin.HandlerFunc { var roles, scope []string loginMethod := "" sessionKey := "" + oidcNonce := "" // OIDC nonce from the original /authorize request if isAuthorizationCodeGrant { if code == "" { @@ -122,13 +124,6 @@ func (h *httpProvider) TokenHandler() gin.HandlerFunc { return } - if codeVerifier == "" && clientSecret == "" { - gc.JSON(http.StatusBadRequest, gin.H{ - "error": "invalid_request", - "error_description": "Either code_verifier or client_secret is required", - }) - return - } // Get state sessionData, err := h.MemoryStoreProvider.GetState(code) if sessionData == "" || err != nil { @@ -140,29 +135,119 @@ func (h *httpProvider) TokenHandler() gin.HandlerFunc { return } - // [0] -> code_challenge + // [0] -> code_challenge (may contain "::method" suffix) or empty // [1] -> session cookie + // [2] -> OIDC nonce from /authorize request (optional) + // [3] -> redirect_uri from /authorize request (optional, for RFC 6749 §4.1.3) sessionDataSplit := strings.Split(sessionData, "@@") // RFC 6749 §4.1.2: Authorization codes MUST be single-use. // Delete synchronously to prevent race condition allowing code reuse. h.MemoryStoreProvider.RemoveState(code) - if codeVerifier != "" { - hash := sha256.New() - hash.Write([]byte(codeVerifier)) - encryptedCode := strings.ReplaceAll(base64.RawURLEncoding.EncodeToString(hash.Sum(nil)), "+", "-") - encryptedCode = strings.ReplaceAll(encryptedCode, "/", "_") - encryptedCode = strings.ReplaceAll(encryptedCode, "=", "") - if encryptedCode != sessionDataSplit[0] { + // RFC 6749 §4.1.3: If redirect_uri was included in the authorization + // request, the token request MUST include the identical redirect_uri. + storedRedirectURI := "" + if len(sessionDataSplit) > 3 { + storedRedirectURI, _ = url.QueryUnescape(sessionDataSplit[3]) + } + requestRedirectURI := strings.TrimSpace(reqBody.RedirectURI) + if storedRedirectURI != "" { + if requestRedirectURI == "" { + gc.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_request", + "error_description": "redirect_uri is required when it was included in the authorization request", + }) + return + } + if subtle.ConstantTimeCompare([]byte(requestRedirectURI), []byte(storedRedirectURI)) != 1 { gc.JSON(http.StatusBadRequest, gin.H{ "error": "invalid_grant", - "error_description": "The code_verifier does not match the code_challenge", + "error_description": "redirect_uri does not match the one used in the authorization request", }) return } + } - } else { + // Parse code_challenge and method from stored state. + // Format: "challenge::method" or just "challenge" (legacy, defaults to S256) + // or empty string (no PKCE — confidential client). + storedChallenge := sessionDataSplit[0] + storedMethod := "S256" + if idx := strings.LastIndex(storedChallenge, "::"); idx >= 0 { + storedMethod = storedChallenge[idx+2:] + storedChallenge = storedChallenge[:idx] + } + + // RFC 7636 §4.5: If PKCE was used at /authorize, the token request + // MUST include code_verifier. This is orthogonal to client authentication. + if storedChallenge != "" && codeVerifier != "" { + // PKCE was used — verify code_verifier against stored challenge + switch storedMethod { + case "S256": + hash := sha256.New() + hash.Write([]byte(codeVerifier)) + computed := base64.RawURLEncoding.EncodeToString(hash.Sum(nil)) + // RFC 7636 Appendix B: code_challenge uses BASE64URL without padding. + // Tolerate clients that send padding ('=') by stripping it before comparison. + normalizedChallenge := strings.TrimRight(storedChallenge, "=") + if subtle.ConstantTimeCompare([]byte(computed), []byte(normalizedChallenge)) != 1 { + gc.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_grant", + "error_description": "The code_verifier does not match the code_challenge", + }) + return + } + case "plain": + // RFC 7636 §4.6: plain method — code_verifier == code_challenge + if subtle.ConstantTimeCompare([]byte(codeVerifier), []byte(storedChallenge)) != 1 { + gc.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_grant", + "error_description": "The code_verifier does not match the code_challenge", + }) + return + } + default: + gc.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_request", + "error_description": "Unsupported code_challenge_method", + }) + return + } + } else if storedChallenge != "" && codeVerifier == "" { + // PKCE was used at /authorize but client didn't send code_verifier + gc.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_request", + "error_description": "code_verifier is required when code_challenge was used", + }) + return + } else if storedChallenge == "" && codeVerifier != "" { + // code_verifier sent but no code_challenge was registered at /authorize. + // Reject to prevent an attacker from bypassing client_secret by + // supplying an arbitrary code_verifier. + gc.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_request", + "error_description": "code_verifier was provided but no code_challenge was registered", + }) + return + } + + // RFC 6749 §4.1.3: Confidential clients MUST authenticate regardless + // of whether PKCE was used. PKCE protects against authorization code + // interception; client authentication is a separate concern. + // When no PKCE was used, client_secret is the sole proof of identity. + if storedChallenge == "" && codeVerifier == "" { + // No PKCE — client_secret is required + if clientSecret == "" { + gc.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_request", + "error_description": "Either code_verifier or client_secret is required", + }) + return + } + } + // Always validate client_secret when provided (confidential client). + if clientSecret != "" { if subtle.ConstantTimeCompare([]byte(clientSecret), []byte(h.Config.ClientSecret)) != 1 { log.Debug().Msg("Client secret is invalid") gc.JSON(http.StatusUnauthorized, gin.H{ @@ -189,6 +274,11 @@ func (h *httpProvider) TokenHandler() gin.HandlerFunc { scope = claims.Scope loginMethod = claims.LoginMethod + // Extract OIDC nonce from stored code data (third @@-separated part). + if len(sessionDataSplit) > 2 { + oidcNonce = sessionDataSplit[2] + } + // rollover the session for security sessionKey = userID if loginMethod != "" { @@ -212,8 +302,8 @@ func (h *httpProvider) TokenHandler() gin.HandlerFunc { if err != nil { log.Debug().Err(err).Msg("Error validating refresh token") gc.JSON(http.StatusUnauthorized, gin.H{ - "error": "unauthorized", - "error_description": err.Error(), + "error": "invalid_grant", + "error_description": "The refresh token is invalid or has expired", }) return } @@ -314,15 +404,13 @@ func (h *httpProvider) TokenHandler() gin.HandlerFunc { } hostname := parsers.GetHost(gc) nonce := uuid.New().String() - if code != "" { - nonce = nonce + "@@" + code - } authToken, err := h.TokenProvider.CreateAuthToken(gc, &token.AuthTokenConfig{ User: user, Roles: roles, Scope: scope, LoginMethod: loginMethod, Nonce: nonce, + OIDCNonce: oidcNonce, HostName: hostname, }) if err != nil { diff --git a/internal/integration_tests/oauth_standards_compliance_test.go b/internal/integration_tests/oauth_standards_compliance_test.go index 47c53cef..8cfdb237 100644 --- a/internal/integration_tests/oauth_standards_compliance_test.go +++ b/internal/integration_tests/oauth_standards_compliance_test.go @@ -250,7 +250,7 @@ func TestTokenEndpointCompliance(t *testing.T) { // Simulate the state that would be set during /authorize sessionToken := "test-session-token" - ts.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+sessionToken) + ts.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+sessionToken+"@@test-nonce") form := url.Values{} form.Set("grant_type", "authorization_code") @@ -328,6 +328,167 @@ func TestTokenEndpointCompliance(t *testing.T) { assert.Equal(t, "invalid_request", body["error"], "Missing refresh_token MUST return 'invalid_request'") }) + + t.Run("RFC6749_redirect_uri_mismatch_returns_invalid_grant", func(t *testing.T) { + // RFC 6749 §4.1.3: redirect_uri in token request must match authorize request + codeVerifier := uuid.New().String() + uuid.New().String() + hash := sha256.Sum256([]byte(codeVerifier)) + codeChallenge := base64.RawURLEncoding.EncodeToString(hash[:]) + code := uuid.New().String() + + // Simulate state with a redirect_uri stored (4th @@-separated segment) + sessionToken := "test-session-token" + ts.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+sessionToken+"@@test-nonce@@"+url.QueryEscape("http://localhost:3000/callback")) + + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("client_id", cfg.ClientID) + form.Set("code", code) + form.Set("code_verifier", codeVerifier) + form.Set("redirect_uri", "http://evil.example.com/callback") + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/oauth/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + router.ServeHTTP(w, req) + + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, "invalid_grant", body["error"], + "RFC 6749 §4.1.3: mismatched redirect_uri MUST return 'invalid_grant'") + }) + + t.Run("RFC6749_redirect_uri_required_when_used_in_authorize", func(t *testing.T) { + // RFC 6749 §4.1.3: if redirect_uri was in authorize, it must be in token + codeVerifier := uuid.New().String() + uuid.New().String() + hash := sha256.Sum256([]byte(codeVerifier)) + codeChallenge := base64.RawURLEncoding.EncodeToString(hash[:]) + code := uuid.New().String() + + sessionToken := "test-session-token" + ts.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+sessionToken+"@@test-nonce@@"+url.QueryEscape("http://localhost:3000/callback")) + + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("client_id", cfg.ClientID) + form.Set("code", code) + form.Set("code_verifier", codeVerifier) + // Note: redirect_uri intentionally omitted + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/oauth/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + router.ServeHTTP(w, req) + + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, "invalid_request", body["error"], + "RFC 6749 §4.1.3: missing redirect_uri MUST return 'invalid_request' when it was used in authorize") + }) + + t.Run("RFC6749_confidential_client_secret_validated_with_PKCE", func(t *testing.T) { + // Confidential clients must authenticate even when PKCE is used. + // Sending a wrong client_secret with a valid code_verifier must fail. + codeVerifier := uuid.New().String() + uuid.New().String() + hash := sha256.Sum256([]byte(codeVerifier)) + codeChallenge := base64.RawURLEncoding.EncodeToString(hash[:]) + code := uuid.New().String() + + sessionToken := "test-session-token" + ts.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+sessionToken+"@@test-nonce") + + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("client_id", cfg.ClientID) + form.Set("code", code) + form.Set("code_verifier", codeVerifier) + form.Set("client_secret", "wrong-secret") + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/oauth/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + router.ServeHTTP(w, req) + + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, "invalid_client", body["error"], + "Confidential client with wrong secret must fail even when PKCE code_verifier is valid") + }) + + t.Run("RFC7636_S256_with_padded_code_challenge", func(t *testing.T) { + // Some clients send code_challenge with base64url padding ('='). + // RFC 7636 Appendix B says "without padding", but the server should + // tolerate padding to interop with clients like Auth0. + codeVerifier := uuid.New().String() + uuid.New().String() + hash := sha256.Sum256([]byte(codeVerifier)) + // Use URLEncoding (WITH padding) to simulate a client that includes '=' + codeChallengeWithPad := base64.URLEncoding.EncodeToString(hash[:]) + code := uuid.New().String() + + sessionToken := "test-session-token" + // Store with padded challenge (as it would come from /authorize query param) + ts.MemoryStoreProvider.SetState(code, codeChallengeWithPad+"::S256@@"+sessionToken+"@@test-nonce") + + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("client_id", cfg.ClientID) + form.Set("code", code) + form.Set("code_verifier", codeVerifier) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/oauth/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + router.ServeHTTP(w, req) + + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + + // Session validation will fail (fake token), but PKCE check must pass. + // If PKCE failed, we'd get "invalid_grant" with "code_verifier does not match". + if body["error"] != nil { + assert.NotEqual(t, "The code_verifier does not match the code_challenge", + body["error_description"], + "S256 PKCE must tolerate padded code_challenge") + } + }) + + t.Run("RFC7636_S256_without_padding_works", func(t *testing.T) { + // Standard case: code_challenge without padding + codeVerifier := uuid.New().String() + uuid.New().String() + hash := sha256.Sum256([]byte(codeVerifier)) + codeChallengeNoPad := base64.RawURLEncoding.EncodeToString(hash[:]) + code := uuid.New().String() + + sessionToken := "test-session-token" + ts.MemoryStoreProvider.SetState(code, codeChallengeNoPad+"::S256@@"+sessionToken+"@@test-nonce") + + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("client_id", cfg.ClientID) + form.Set("code", code) + form.Set("code_verifier", codeVerifier) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/oauth/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + router.ServeHTTP(w, req) + + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + + // PKCE check must pass; subsequent session validation may fail (fake token) + if body["error"] != nil { + assert.NotEqual(t, "The code_verifier does not match the code_challenge", + body["error_description"], + "S256 PKCE must work with unpadded code_challenge") + } + }) } // TestRevocationEndpointCompliance verifies /oauth/revoke complies with RFC 7009 @@ -660,7 +821,7 @@ func TestAuthorizeEndpointCompliance(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) }) - t.Run("RFC7636_unsupported_code_challenge_method_returns_error", func(t *testing.T) { + t.Run("RFC7636_plain_code_challenge_method_is_accepted", func(t *testing.T) { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/authorize?client_id="+cfg.ClientID+ @@ -668,14 +829,25 @@ func TestAuthorizeEndpointCompliance(t *testing.T) { "&code_challenge=test-challenge&code_challenge_method=plain", nil) router.ServeHTTP(w, req) + // plain is accepted per RFC 7636 §4.2 — should not return 400 + assert.NotEqual(t, http.StatusBadRequest, w.Code, + "RFC 7636: plain code_challenge_method MUST be accepted") + }) + + t.Run("RFC7636_unsupported_code_challenge_method_returns_error", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", + "/authorize?client_id="+cfg.ClientID+ + "&response_type=code&state=test-state&response_mode=query"+ + "&code_challenge=test-challenge&code_challenge_method=unsupported", nil) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) var body map[string]interface{} json.Unmarshal(w.Body.Bytes(), &body) assert.Equal(t, "invalid_request", body["error"], "RFC 7636: unsupported code_challenge_method MUST return 'invalid_request'") - assert.Contains(t, body["error_description"].(string), "S256", - "RFC 7636: error_description should mention S256") }) t.Run("RFC6749_invalid_response_mode_returns_error", func(t *testing.T) { @@ -686,6 +858,126 @@ func TestAuthorizeEndpointCompliance(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) }) + + t.Run("OIDC_nonce_required_for_id_token_response_type", func(t *testing.T) { + // OIDC Core §3.2.2.1: nonce is REQUIRED when response_type contains id_token + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", + "/authorize?client_id="+cfg.ClientID+ + "&response_type=id_token&state=test-state&response_mode=fragment", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + assert.Equal(t, "invalid_request", body["error"], + "OIDC Core: missing nonce for response_type=id_token MUST return invalid_request") + assert.Contains(t, body["error_description"], "nonce", + "error_description should mention nonce") + }) + + t.Run("OIDC_nonce_required_for_id_token_token_response_type", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", + "/authorize?client_id="+cfg.ClientID+ + "&response_type="+url.QueryEscape("id_token token")+ + "&state=test-state&response_mode=fragment", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + assert.Equal(t, "invalid_request", body["error"], + "OIDC Core: missing nonce for response_type=id_token token MUST return invalid_request") + }) + + t.Run("OIDC_nonce_required_for_code_id_token_response_type", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", + "/authorize?client_id="+cfg.ClientID+ + "&response_type="+url.QueryEscape("code id_token")+ + "&state=test-state&response_mode=fragment", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + assert.Equal(t, "invalid_request", body["error"], + "OIDC Core: missing nonce for response_type=code id_token MUST return invalid_request") + }) + + t.Run("OIDC_nonce_not_required_for_code_response_type", func(t *testing.T) { + // nonce is OPTIONAL for response_type=code — should NOT return 400 + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", + "/authorize?client_id="+cfg.ClientID+ + "&response_type=code&state=test-state&response_mode=query", nil) + router.ServeHTTP(w, req) + + // Should NOT be 400 for missing nonce (it will redirect to login) + assert.NotEqual(t, http.StatusBadRequest, w.Code, + "nonce is optional for response_type=code — should not return 400") + }) + + t.Run("OIDC_nonce_not_required_for_token_response_type", func(t *testing.T) { + // nonce is OPTIONAL for response_type=token (pure OAuth 2.0) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", + "/authorize?client_id="+cfg.ClientID+ + "&response_type=token&state=test-state&response_mode=fragment", nil) + router.ServeHTTP(w, req) + + assert.NotEqual(t, http.StatusBadRequest, w.Code, + "nonce is optional for response_type=token — should not return 400") + }) + + t.Run("OIDC_query_mode_rejected_for_token_response_type", func(t *testing.T) { + // Tokens MUST NOT be encoded in query strings + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", + "/authorize?client_id="+cfg.ClientID+ + "&response_type=token&state=test-state&response_mode=query", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + assert.Equal(t, "invalid_request", body["error"], + "query response_mode MUST be rejected for response_type=token") + }) + + t.Run("OIDC_query_mode_rejected_for_id_token_response_type", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", + "/authorize?client_id="+cfg.ClientID+ + "&response_type=id_token&state=test-state&response_mode=query&nonce=test-nonce", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + assert.Equal(t, "invalid_request", body["error"], + "query response_mode MUST be rejected for response_type=id_token") + }) + + t.Run("OIDC_query_mode_rejected_for_hybrid_response_types", func(t *testing.T) { + for _, rt := range []string{"code id_token", "code token", "code id_token token"} { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", + "/authorize?client_id="+cfg.ClientID+ + "&response_type="+url.QueryEscape(rt)+ + "&state=test-state&response_mode=query&nonce=test-nonce", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code, + "query response_mode MUST be rejected for response_type=%s", rt) + } + }) } // TestJWKSEndpointCompliance verifies /.well-known/jwks.json diff --git a/internal/token/auth_token.go b/internal/token/auth_token.go index 62818fe3..381257f7 100644 --- a/internal/token/auth_token.go +++ b/internal/token/auth_token.go @@ -44,14 +44,19 @@ var reservedClaims = map[string]bool{ type AuthTokenConfig struct { LoginMethod string Nonce string - Code string - AtHash string - CodeHash string - ExpireTime string - User *schemas.User - HostName string - Roles []string - Scope []string + // OIDCNonce is the nonce value from the original OIDC /authorize + // request. When set, CreateIDToken uses this for the id_token "nonce" + // claim instead of Nonce. This separates the OIDC nonce (client- + // provided, echoed back) from the internal session nonce (Nonce). + OIDCNonce string + Code string + AtHash string + CodeHash string + ExpireTime string + User *schemas.User + HostName string + Roles []string + Scope []string // AuthTime is the Unix timestamp (seconds) at which the user // authenticated. OIDC Core §2 defines this as the `auth_time` ID // token claim. If zero, CreateIDToken falls back to time.Now() so @@ -453,8 +458,16 @@ func (p *provider) CreateIDToken(cfg *AuthTokenConfig) (string, int64, error) { if cfg.CodeHash != "" { customClaims["c_hash"] = cfg.CodeHash } - if cfg.Nonce != "" { - customClaims["nonce"] = cfg.Nonce + // OIDC Core §3.1.3.3: the nonce claim MUST echo the value from the + // original authorize request. OIDCNonce carries that value when the + // token is issued via the token endpoint (code flow). For implicit + // flows the caller sets Nonce directly. + idTokenNonce := cfg.OIDCNonce + if idTokenNonce == "" { + idTokenNonce = cfg.Nonce + } + if idTokenNonce != "" { + customClaims["nonce"] = idTokenNonce } // OIDC Core §2: auth_time — Unix seconds. Default to now if caller // did not supply a session-level auth timestamp (backward compat).