package oidc import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/gin-gonic/gin" "github.com/ory/fosite" "github.com/stretchr/testify/require" "github.com/pocket-id/pocket-id/backend/internal/model" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" ) // TestUserInfoHandler covers the userinfo endpoint, which returns user PII based purely on // a presented access token. It must reject a missing/garbage token and — importantly — a // token that carries no resource owner (e.g. a client_credentials token), so machine // tokens cannot be exchanged for a user's profile. A valid user access token returns the // claims for exactly the scopes it was granted. func TestUserInfoHandler(t *testing.T) { gin.SetMode(gin.TestMode) const ( baseURL = "https://issuer.example.com" userID = "user-1" clientID = "client-1" ) db := testutils.NewDatabaseForTest(t) require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client"}).Error) require.NoError(t, db.Create(&model.User{ Base: model.Base{ID: userID}, Username: "tim", FirstName: "Tim", LastName: "Cook", DisplayName: "Tim Cook", Email: stringPointer("tim@example.com"), EmailVerified: true, }).Error) key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) provider, err := newProvider(NewStore(db, nil).WithIssuer(baseURL), nil, testTokenSigner{key: key}, Config{ BaseURL: baseURL, TokenBaseURL: baseURL, Secret: []byte("test-secret"), }) require.NoError(t, err) handler := newUserInfoHandler(provider, newClaimsService(db, nil, baseURL, nil), baseURL) issueAccessToken := func(t *testing.T, requestID, subject string, scopes ...string) string { t.Helper() session := NewEmptySession() session.Subject = subject session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour)) request := fosite.NewAccessRequest(session) request.ID = requestID request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}} request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)} request.RequestedScope = fosite.Arguments(scopes) request.GrantedScope = fosite.Arguments(scopes) // The grant is bound to the requesting client // The issuer store adds the issuer audience when the token carries an identity scope, which is what userinfo gates on request.RequestedAudience = fosite.Arguments{clientID} request.GrantedAudience = fosite.Arguments{clientID} response, err := provider.NewAccessResponse(t.Context(), request) require.NoError(t, err) return response.GetAccessToken() } call := func(t *testing.T, token string) (*httptest.ResponseRecorder, *gin.Context) { t.Helper() req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/oidc/userinfo", nil) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = req handler.userInfo(c) return rec, c } t.Run("valid user access token returns the granted claims", func(t *testing.T) { token := issueAccessToken(t, "req-valid", userID, "openid", "email") rec, c := call(t, token) require.Empty(t, c.Errors) require.Equal(t, http.StatusOK, rec.Code) var claims map[string]any require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &claims)) require.Equal(t, userID, claims["sub"]) require.Equal(t, "tim@example.com", claims["email"]) // profile was not granted, so profile claims must be absent require.NotContains(t, claims, "given_name") }) t.Run("token without the openid scope is rejected", func(t *testing.T) { // A token issued purely for a custom API carries no openid scope and must not read profile claims session := NewEmptySession() session.Subject = userID session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour)) request := fosite.NewAccessRequest(session) request.ID = "req-no-openid" request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}} request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)} request.RequestedScope = fosite.Arguments{"read:orders"} request.GrantedScope = fosite.Arguments{"read:orders"} request.RequestedAudience = fosite.Arguments{"https://api.example.com"} request.GrantedAudience = fosite.Arguments{"https://api.example.com"} response, err := provider.NewAccessResponse(t.Context(), request) require.NoError(t, err) rec, c := call(t, response.GetAccessToken()) require.Empty(t, c.Errors) require.Equal(t, http.StatusForbidden, rec.Code) }) t.Run("token audienced to a custom API can read userinfo when openid was granted", func(t *testing.T) { // A token that requested identity scopes alongside a custom API resource is materialized with the issuer added to its audience, so it may read userinfo by the client's explicit opt-in session := NewEmptySession() session.Subject = userID session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour)) request := fosite.NewAccessRequest(session) request.ID = "req-api-audience" request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}} request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)} request.RequestedScope = fosite.Arguments{"openid", "email", "read:orders"} request.GrantedScope = fosite.Arguments{"openid", "email", "read:orders"} request.RequestedAudience = fosite.Arguments{"https://api.orders.example.com"} request.GrantedAudience = fosite.Arguments{"https://api.orders.example.com"} response, err := provider.NewAccessResponse(t.Context(), request) require.NoError(t, err) rec, c := call(t, response.GetAccessToken()) require.Empty(t, c.Errors) require.Equal(t, http.StatusOK, rec.Code) var claims map[string]any require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &claims)) require.Equal(t, userID, claims["sub"]) require.Equal(t, "tim@example.com", claims["email"]) }) t.Run("token audienced only to a custom API is rejected", func(t *testing.T) { // A token that carries no identity scope never gains the issuer audience, so it cannot be replayed at userinfo even though it has a valid resource owner session := NewEmptySession() session.Subject = userID session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour)) request := fosite.NewAccessRequest(session) request.ID = "req-api-only" request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}} request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)} request.RequestedScope = fosite.Arguments{"read:orders"} request.GrantedScope = fosite.Arguments{"read:orders"} request.RequestedAudience = fosite.Arguments{"https://api.orders.example.com"} request.GrantedAudience = fosite.Arguments{"https://api.orders.example.com"} response, err := provider.NewAccessResponse(t.Context(), request) require.NoError(t, err) rec, c := call(t, response.GetAccessToken()) require.Empty(t, c.Errors) require.Equal(t, http.StatusForbidden, rec.Code) require.Contains(t, rec.Body.String(), "not audienced to this server") }) t.Run("token whose user no longer exists is rejected with 401", func(t *testing.T) { // A valid token whose subject was deleted is an auth failure, not a 404 token := issueAccessToken(t, "req-ghost", "ghost-user", "openid") rec, c := call(t, token) require.Empty(t, c.Errors) require.Equal(t, http.StatusUnauthorized, rec.Code) require.Contains(t, rec.Header().Get("WWW-Authenticate"), `Bearer error="request_unauthorized"`) }) t.Run("missing access token is rejected", func(t *testing.T) { rec, c := call(t, "") require.Empty(t, c.Errors) require.Equal(t, http.StatusUnauthorized, rec.Code) require.Contains(t, rec.Header().Get("WWW-Authenticate"), `Bearer error="request_unauthorized"`) }) t.Run("garbage access token is rejected", func(t *testing.T) { rec, c := call(t, "garbage.token.value") require.Empty(t, c.Errors) require.Equal(t, http.StatusUnauthorized, rec.Code) require.Contains(t, rec.Header().Get("WWW-Authenticate"), `Bearer error=`) }) t.Run("token without a resource owner is rejected", func(t *testing.T) { // client_credentials-style token: valid, but no subject -> must not return PII. token := issueAccessToken(t, "req-no-subject", "", "openid") rec, c := call(t, token) require.Empty(t, c.Errors) require.Equal(t, http.StatusUnauthorized, rec.Code) require.Contains(t, rec.Header().Get("WWW-Authenticate"), `Bearer error="request_unauthorized"`) }) }