// Copyright © 2024 Ory Corp // SPDX-License-Identifier: Apache-2.0 package oauth2_test import ( "context" "net/http" "strings" "testing" "time" "github.com/pborman/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" "golang.org/x/oauth2" hydra "github.com/ory/hydra-client-go/v2" "github.com/ory/hydra/v2/client" "github.com/ory/hydra/v2/driver" "github.com/ory/hydra/v2/driver/config" "github.com/ory/hydra/v2/fosite" "github.com/ory/hydra/v2/fosite/handler/openid" "github.com/ory/hydra/v2/fosite/token/jwt" "github.com/ory/hydra/v2/internal/testhelpers" hydraoauth2 "github.com/ory/hydra/v2/oauth2" "github.com/ory/hydra/v2/x" "github.com/ory/x/configx" "github.com/ory/x/pointerx" ) func TestDeviceAuthRequest(t *testing.T) { t.Parallel() ctx := context.Background() reg := testhelpers.NewRegistryMemory(t) testhelpers.NewOAuth2Server(ctx, t, reg) secret := uuid.New() c := &client.Client{ ID: "device-client", Secret: secret, GrantTypes: []string{"urn:ietf:params:oauth:grant-type:device_code"}, Scope: "hydra offline openid", Audience: []string{"https://api.ory.sh/"}, TokenEndpointAuthMethod: "client_secret_post", } require.NoError(t, reg.ClientManager().CreateClient(ctx, c)) oauthClient := &oauth2.Config{ ClientID: c.GetID(), ClientSecret: secret, Endpoint: oauth2.Endpoint{ DeviceAuthURL: reg.Config().OAuth2DeviceAuthorisationURL(ctx).String(), TokenURL: reg.Config().OAuth2TokenURL(ctx).String(), AuthStyle: oauth2.AuthStyleInParams, }, Scopes: strings.Split(c.Scope, " "), } testCases := []struct { description string setUp func() check func(t *testing.T, resp *oauth2.DeviceAuthResponse, err error) cleanUp func() }{ { description: "should pass", check: func(t *testing.T, resp *oauth2.DeviceAuthResponse, _ error) { assert.NotEmpty(t, resp.DeviceCode) assert.NotEmpty(t, resp.UserCode) assert.NotEmpty(t, resp.Interval) assert.NotEmpty(t, resp.VerificationURI) assert.NotEmpty(t, resp.VerificationURIComplete) }, }, } for _, testCase := range testCases { t.Run("case="+testCase.description, func(t *testing.T) { if testCase.setUp != nil { testCase.setUp() } resp, err := oauthClient.DeviceAuth(context.Background(), []oauth2.AuthCodeOption{oauth2.SetAuthURLParam("client_secret", secret)}...) if testCase.check != nil { testCase.check(t, resp, err) } if testCase.cleanUp != nil { testCase.cleanUp() } }) } } func TestDeviceTokenRequest(t *testing.T) { t.Parallel() ctx := context.Background() reg := testhelpers.NewRegistryMemory(t) testhelpers.NewOAuth2Server(ctx, t, reg) secret := uuid.New() c := &client.Client{ ID: "device-client", Secret: secret, GrantTypes: []string{ string(fosite.GrantTypeDeviceCode), string(fosite.GrantTypeRefreshToken), }, Scope: "hydra offline openid", Audience: []string{"https://api.ory.sh/"}, } require.NoError(t, reg.ClientManager().CreateClient(ctx, c)) oauthClient := &oauth2.Config{ ClientID: c.GetID(), ClientSecret: secret, Endpoint: oauth2.Endpoint{ DeviceAuthURL: reg.Config().OAuth2DeviceAuthorisationURL(ctx).String(), TokenURL: reg.Config().OAuth2TokenURL(ctx).String(), AuthStyle: oauth2.AuthStyleInHeader, }, Scopes: strings.Split(c.Scope, " "), } testCases := []struct { description string setUp func(signature, userCodeSignature string) check func(t *testing.T, token *oauth2.Token, err error) cleanUp func() }{ { description: "should pass with refresh token", setUp: func(signature, userCodeSignature string) { authreq := &fosite.DeviceRequest{ UserCodeState: fosite.UserCodeAccepted, Request: fosite.Request{ Client: &fosite.DefaultClient{ ID: c.GetID(), GrantTypes: []string{string(fosite.GrantTypeDeviceCode)}, }, RequestedScope: []string{"hydra", "offline"}, GrantedScope: []string{"hydra", "offline"}, Session: &hydraoauth2.Session{ DefaultSession: &openid.DefaultSession{ Claims: &jwt.IDTokenClaims{ Subject: "hydra", }, ExpiresAt: map[fosite.TokenType]time.Time{ fosite.DeviceCode: time.Now().Add(time.Hour).UTC(), }, }, }, RequestedAt: time.Now(), }, } require.NoError(t, reg.OAuth2Storage().CreateDeviceAuthSession(context.TODO(), signature, userCodeSignature, authreq)) }, check: func(t *testing.T, token *oauth2.Token, err error) { assert.NotEmpty(t, token.AccessToken) assert.NotEmpty(t, token.RefreshToken) }, }, { description: "should pass with ID token", setUp: func(signature, userCodeSignature string) { authreq := &fosite.DeviceRequest{ UserCodeState: fosite.UserCodeAccepted, Request: fosite.Request{ Client: &fosite.DefaultClient{ ID: c.GetID(), GrantTypes: []string{string(fosite.GrantTypeDeviceCode)}, }, RequestedScope: []string{"hydra", "offline", "openid"}, GrantedScope: []string{"hydra", "offline", "openid"}, Session: &hydraoauth2.Session{ DefaultSession: &openid.DefaultSession{ Claims: &jwt.IDTokenClaims{ Subject: "hydra", }, ExpiresAt: map[fosite.TokenType]time.Time{ fosite.DeviceCode: time.Now().Add(time.Hour).UTC(), }, }, }, RequestedAt: time.Now(), }, } require.NoError(t, reg.OAuth2Storage().CreateDeviceAuthSession(context.TODO(), signature, userCodeSignature, authreq)) require.NoError(t, reg.OAuth2Storage().CreateOpenIDConnectSession(context.TODO(), signature, authreq)) }, check: func(t *testing.T, token *oauth2.Token, err error) { assert.NotEmpty(t, token.AccessToken) assert.NotEmpty(t, token.RefreshToken) assert.NotEmpty(t, token.Extra("id_token")) }, }, } for _, testCase := range testCases { t.Run("case="+testCase.description, func(t *testing.T) { code, signature, err := reg.DeviceCodeStrategy().GenerateDeviceCode(context.TODO()) require.NoError(t, err) _, userCodeSignature, err := reg.UserCodeStrategy().GenerateUserCode(context.TODO()) require.NoError(t, err) if testCase.setUp != nil { testCase.setUp(signature, userCodeSignature) } var token *oauth2.Token token, err = oauthClient.DeviceAccessToken(context.Background(), &oauth2.DeviceAuthResponse{DeviceCode: code}) if testCase.check != nil { testCase.check(t, token, err) } if testCase.cleanUp != nil { testCase.cleanUp() } }) } } func TestDeviceCodeWithDefaultStrategy(t *testing.T) { t.Parallel() ctx := t.Context() reg := testhelpers.NewRegistryMemory(t, driver.WithConfigOptions(configx.WithValues(map[string]any{ config.KeyAccessTokenStrategy: "opaque", config.KeyRefreshTokenHook: "", }))) publicTS, adminTS := testhelpers.NewOAuth2Server(ctx, t, reg) publicClient := hydra.NewAPIClient(hydra.NewConfiguration()) publicClient.GetConfig().Servers = hydra.ServerConfigurations{{URL: publicTS.URL}} adminClient := hydra.NewAPIClient(hydra.NewConfiguration()) adminClient.GetConfig().Servers = hydra.ServerConfigurations{{URL: adminTS.URL}} getDeviceCode := func(t *testing.T, conf *oauth2.Config, c *http.Client, params ...oauth2.AuthCodeOption) (*oauth2.DeviceAuthResponse, error) { return conf.DeviceAuth(ctx, params...) } acceptUserCode := func(t *testing.T, conf *oauth2.Config, c *http.Client, devResp *oauth2.DeviceAuthResponse) *http.Response { if c == nil { c = testhelpers.NewEmptyJarClient(t) } resp, err := c.Get(devResp.VerificationURIComplete) require.NoError(t, err) require.Contains(t, reg.Config().DeviceDoneURL(ctx).String(), resp.Request.URL.Path, "did not end up in post device URL") require.Equal(t, resp.Request.URL.Query().Get("client_id"), conf.ClientID) return resp } acceptDeviceHandler := func(t *testing.T, c *client.Client) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userCode := r.URL.Query().Get("user_code") payload := hydra.AcceptDeviceUserCodeRequest{ UserCode: &userCode, } v, _, err := adminClient.OAuth2API.AcceptUserCodeRequest(context.Background()). DeviceChallenge(r.URL.Query().Get("device_challenge")). AcceptDeviceUserCodeRequest(payload). Execute() require.NoError(t, err) require.NotEmpty(t, v.RedirectTo) http.Redirect(w, r, v.RedirectTo, http.StatusFound) } } acceptLoginHandler := func(t *testing.T, c *client.Client, subject string, scopes []string, checkRequestPayload func(request *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { rr, _, err := adminClient.OAuth2API.GetOAuth2LoginRequest(context.Background()).LoginChallenge(r.URL.Query().Get("login_challenge")).Execute() require.NoError(t, err) assert.EqualValues(t, c.GetID(), pointerx.Deref(rr.Client.ClientId)) assert.Empty(t, pointerx.Deref(rr.Client.ClientSecret)) assert.EqualValues(t, c.GrantTypes, rr.Client.GrantTypes) assert.EqualValues(t, c.LogoURI, pointerx.Deref(rr.Client.LogoUri)) assert.EqualValues(t, r.URL.Query().Get("login_challenge"), rr.Challenge) assert.EqualValues(t, scopes, rr.RequestedScope) assert.Contains(t, rr.RequestUrl, hydraoauth2.DeviceVerificationPath) acceptBody := hydra.AcceptOAuth2LoginRequest{ Subject: subject, Remember: pointerx.Ptr(!rr.Skip), Acr: pointerx.Ptr("1"), Amr: []string{"pwd"}, Context: map[string]interface{}{"context": "bar"}, } if checkRequestPayload != nil { if b := checkRequestPayload(rr); b != nil { acceptBody = *b } } v, _, err := adminClient.OAuth2API.AcceptOAuth2LoginRequest(context.Background()). LoginChallenge(r.URL.Query().Get("login_challenge")). AcceptOAuth2LoginRequest(acceptBody). Execute() require.NoError(t, err) require.NotEmpty(t, v.RedirectTo) http.Redirect(w, r, v.RedirectTo, http.StatusFound) } } acceptConsentHandler := func(t *testing.T, c *client.Client, subject string, scopes []string, checkRequestPayload func(*hydra.OAuth2ConsentRequest)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { rr, _, err := adminClient.OAuth2API.GetOAuth2ConsentRequest(context.Background()).ConsentChallenge(r.URL.Query().Get("consent_challenge")).Execute() require.NoError(t, err) assert.EqualValues(t, c.GetID(), pointerx.Deref(rr.Client.ClientId)) assert.Empty(t, pointerx.Deref(rr.Client.ClientSecret)) assert.EqualValues(t, c.GrantTypes, rr.Client.GrantTypes) assert.EqualValues(t, c.LogoURI, pointerx.Deref(rr.Client.LogoUri)) assert.EqualValues(t, subject, pointerx.Deref(rr.Subject)) assert.EqualValues(t, scopes, rr.RequestedScope) assert.Contains(t, *rr.RequestUrl, hydraoauth2.DeviceVerificationPath) if checkRequestPayload != nil { checkRequestPayload(rr) } assert.Equal(t, map[string]interface{}{"context": "bar"}, rr.Context) v, _, err := adminClient.OAuth2API.AcceptOAuth2ConsentRequest(context.Background()). ConsentChallenge(r.URL.Query().Get("consent_challenge")). AcceptOAuth2ConsentRequest(hydra.AcceptOAuth2ConsentRequest{ GrantScope: scopes, Remember: pointerx.Ptr(true), RememberFor: pointerx.Ptr[int64](0), GrantAccessTokenAudience: rr.RequestedAccessTokenAudience, Session: &hydra.AcceptOAuth2ConsentRequestSession{ AccessToken: map[string]interface{}{"foo": "bar"}, IdToken: map[string]interface{}{"bar": "baz"}, }, }). Execute() require.NoError(t, err) require.NotEmpty(t, v.RedirectTo) http.Redirect(w, r, v.RedirectTo, http.StatusFound) } } assertRefreshToken := func(t *testing.T, token *oauth2.Token, c *oauth2.Config, expectedExp time.Time) { actualExp := testhelpers.IntrospectToken(t, token.RefreshToken, adminTS).Get("exp").Int() assert.WithinDuration(t, expectedExp, time.Unix(actualExp, 0), time.Second) } assertIDToken := func(t *testing.T, token *oauth2.Token, c *oauth2.Config, expectedSubject, expectedNonce string, expectedExp time.Time) gjson.Result { idt, ok := token.Extra("id_token").(string) require.True(t, ok) assert.NotEmpty(t, idt) claims := gjson.ParseBytes(testhelpers.InsecureDecodeJWT(t, idt)) assert.True(t, time.Now().After(time.Unix(claims.Get("iat").Int(), 0)), "%s", claims) assert.True(t, time.Now().After(time.Unix(claims.Get("nbf").Int(), 0)), "%s", claims) assert.True(t, time.Now().Before(time.Unix(claims.Get("exp").Int(), 0)), "%s", claims) assert.WithinDuration(t, expectedExp, time.Unix(claims.Get("exp").Int(), 0), 2*time.Second) assert.NotEmpty(t, claims.Get("jti").String(), "%s", claims) assert.EqualValues(t, reg.Config().IssuerURL(ctx).String(), claims.Get("iss").String(), "%s", claims) assert.NotEmpty(t, claims.Get("sid").String(), "%s", claims) assert.Equal(t, "1", claims.Get("acr").String(), "%s", claims) require.Len(t, claims.Get("amr").Array(), 1, "%s", claims) assert.EqualValues(t, "pwd", claims.Get("amr").Array()[0].String(), "%s", claims) require.Len(t, claims.Get("aud").Array(), 1, "%s", claims) assert.EqualValues(t, c.ClientID, claims.Get("aud").Array()[0].String(), "%s", claims) assert.EqualValues(t, expectedSubject, claims.Get("sub").String(), "%s", claims) assert.EqualValues(t, `baz`, claims.Get("bar").String(), "%s", claims) return claims } introspectAccessToken := func(t *testing.T, conf *oauth2.Config, token *oauth2.Token, expectedSubject string) gjson.Result { require.NotEmpty(t, token.AccessToken) i := testhelpers.IntrospectToken(t, token.AccessToken, adminTS) assert.True(t, i.Get("active").Bool(), "%s", i) assert.EqualValues(t, conf.ClientID, i.Get("client_id").String(), "%s", i) assert.EqualValues(t, expectedSubject, i.Get("sub").String(), "%s", i) assert.EqualValues(t, `bar`, i.Get("ext.foo").String(), "%s", i) return i } assertJWTAccessToken := func(t *testing.T, strat string, conf *oauth2.Config, token *oauth2.Token, expectedSubject string, expectedExp time.Time, scopes string) gjson.Result { require.NotEmpty(t, token.AccessToken) parts := strings.Split(token.AccessToken, ".") if strat != "jwt" { require.Len(t, parts, 2) return gjson.Parse("null") } require.Len(t, parts, 3) i := gjson.ParseBytes(testhelpers.InsecureDecodeJWT(t, token.AccessToken)) assert.NotEmpty(t, i.Get("jti").String()) assert.EqualValues(t, conf.ClientID, i.Get("client_id").String(), "%s", i) assert.EqualValues(t, expectedSubject, i.Get("sub").String(), "%s", i) assert.EqualValues(t, reg.Config().IssuerURL(ctx).String(), i.Get("iss").String(), "%s", i) assert.True(t, time.Now().After(time.Unix(i.Get("iat").Int(), 0)), "%s", i) assert.True(t, time.Now().After(time.Unix(i.Get("nbf").Int(), 0)), "%s", i) assert.True(t, time.Now().Before(time.Unix(i.Get("exp").Int(), 0)), "%s", i) assert.WithinDuration(t, expectedExp, time.Unix(i.Get("exp").Int(), 0), time.Second) assert.EqualValues(t, `bar`, i.Get("ext.foo").String(), "%s", i) assert.EqualValues(t, scopes, i.Get("scp").Raw, "%s", i) return i } waitForRefreshTokenExpiry := func() { time.Sleep(reg.Config().GetRefreshTokenLifespan(ctx) + time.Second) } t.Run("case=checks if request fails when audience does not match", func(t *testing.T) { testhelpers.NewLoginConsentUI(t, reg.Config(), testhelpers.HTTPServerNoExpectedCallHandler(t), testhelpers.HTTPServerNoExpectedCallHandler(t)) _, conf := newDeviceClient(t, reg) resp, err := conf.DeviceAuth(ctx, oauth2.SetAuthURLParam("audience", "https://not-ory-api/")) require.Error(t, err) var devErr *oauth2.RetrieveError require.ErrorAs(t, err, &devErr) require.Nil(t, resp) require.Equal(t, devErr.Response.StatusCode, http.StatusBadRequest) }) subject := "aeneas-rekkas" nonce := uuid.New() t.Run("case=perform device flow without ID and refresh tokens", func(t *testing.T) { c, conf := newDeviceClient(t, reg) conf.Scopes = []string{"hydra"} testhelpers.NewDeviceLoginConsentUI(t, reg.Config(), acceptDeviceHandler(t, c), acceptLoginHandler(t, c, subject, conf.Scopes, nil), acceptConsentHandler(t, c, subject, conf.Scopes, nil), ) resp, err := getDeviceCode(t, conf, nil) require.NoError(t, err) require.NotEmpty(t, resp.DeviceCode) require.NotEmpty(t, resp.UserCode) loginFlowResp := acceptUserCode(t, conf, nil, resp) require.NotNil(t, loginFlowResp) token, err := conf.DeviceAccessToken(context.Background(), resp) require.NoError(t, err) assert.Empty(t, token.Extra("c_nonce_draft_00"), "should not be set if not requested") assert.Empty(t, token.Extra("c_nonce_expires_in_draft_00"), "should not be set if not requested") introspectAccessToken(t, conf, token, subject) assert.Empty(t, token.Extra("id_token")) assert.Empty(t, token.RefreshToken) }) t.Run("case=perform device flow with ID token", func(t *testing.T) { c, conf := newDeviceClient(t, reg) conf.Scopes = []string{"openid", "hydra"} testhelpers.NewDeviceLoginConsentUI(t, reg.Config(), acceptDeviceHandler(t, c), acceptLoginHandler(t, c, subject, conf.Scopes, nil), acceptConsentHandler(t, c, subject, conf.Scopes, nil), ) resp, err := getDeviceCode(t, conf, nil) require.NoError(t, err) require.NotEmpty(t, resp.DeviceCode) require.NotEmpty(t, resp.UserCode) loginFlowResp := acceptUserCode(t, conf, nil, resp) require.NotNil(t, loginFlowResp) token, err := conf.DeviceAccessToken(context.Background(), resp) iat := time.Now() require.NoError(t, err) assert.Empty(t, token.Extra("c_nonce_draft_00"), "should not be set if not requested") assert.Empty(t, token.Extra("c_nonce_expires_in_draft_00"), "should not be set if not requested") introspectAccessToken(t, conf, token, subject) assertIDToken(t, token, conf, subject, nonce, iat.Add(reg.Config().GetIDTokenLifespan(ctx))) assert.Empty(t, token.RefreshToken) }) t.Run("case=perform device flow with refresh token", func(t *testing.T) { c, conf := newDeviceClient(t, reg) conf.Scopes = []string{"hydra", "offline"} testhelpers.NewDeviceLoginConsentUI(t, reg.Config(), acceptDeviceHandler(t, c), acceptLoginHandler(t, c, subject, conf.Scopes, nil), acceptConsentHandler(t, c, subject, conf.Scopes, nil), ) resp, err := getDeviceCode(t, conf, nil) require.NoError(t, err) require.NotEmpty(t, resp.DeviceCode) require.NotEmpty(t, resp.UserCode) loginFlowResp := acceptUserCode(t, conf, nil, resp) require.NotNil(t, loginFlowResp) token, err := conf.DeviceAccessToken(context.Background(), resp) iat := time.Now() require.NoError(t, err) assert.Empty(t, token.Extra("c_nonce_draft_00"), "should not be set if not requested") assert.Empty(t, token.Extra("c_nonce_expires_in_draft_00"), "should not be set if not requested") introspectAccessToken(t, conf, token, subject) assert.Empty(t, token.Extra("id_token")) assertRefreshToken(t, token, conf, iat.Add(reg.Config().GetRefreshTokenLifespan(ctx))) }) t.Run("case=perform device flow with ID token and refresh tokens", func(t *testing.T) { run := func(t *testing.T, strategy string) { c, conf := newDeviceClient(t, reg) testhelpers.NewDeviceLoginConsentUI(t, reg.Config(), acceptDeviceHandler(t, c), acceptLoginHandler(t, c, subject, conf.Scopes, nil), acceptConsentHandler(t, c, subject, conf.Scopes, nil), ) resp, err := getDeviceCode(t, conf, nil) require.NoError(t, err) require.NotEmpty(t, resp.DeviceCode) require.NotEmpty(t, resp.UserCode) loginFlowResp := acceptUserCode(t, conf, nil, resp) require.NotNil(t, loginFlowResp) token, err := conf.DeviceAccessToken(context.Background(), resp) iat := time.Now() require.NoError(t, err) assert.Empty(t, token.Extra("c_nonce_draft_00"), "should not be set if not requested") assert.Empty(t, token.Extra("c_nonce_expires_in_draft_00"), "should not be set if not requested") introspectAccessToken(t, conf, token, subject) assertJWTAccessToken(t, strategy, conf, token, subject, iat.Add(reg.Config().GetAccessTokenLifespan(ctx)), `["hydra","offline","openid"]`) assertIDToken(t, token, conf, subject, nonce, iat.Add(reg.Config().GetIDTokenLifespan(ctx))) assertRefreshToken(t, token, conf, iat.Add(reg.Config().GetRefreshTokenLifespan(ctx))) t.Run("followup=successfully perform refresh token flow", func(t *testing.T) { require.NotEmpty(t, token.RefreshToken) token.Expiry = token.Expiry.Add(-time.Hour * 24) iat = time.Now() refreshedToken, err := conf.TokenSource(context.Background(), token).Token() require.NoError(t, err) require.NotEqual(t, token.AccessToken, refreshedToken.AccessToken) require.NotEqual(t, token.RefreshToken, refreshedToken.RefreshToken) require.NotEqual(t, token.Extra("id_token"), refreshedToken.Extra("id_token")) introspectAccessToken(t, conf, refreshedToken, subject) t.Run("followup=refreshed tokens contain valid tokens", func(t *testing.T) { assertJWTAccessToken(t, strategy, conf, refreshedToken, subject, iat.Add(reg.Config().GetAccessTokenLifespan(ctx)), `["hydra","offline","openid"]`) assertIDToken(t, refreshedToken, conf, subject, nonce, iat.Add(reg.Config().GetIDTokenLifespan(ctx))) assertRefreshToken(t, refreshedToken, conf, iat.Add(reg.Config().GetRefreshTokenLifespan(ctx))) }) t.Run("followup=original access token is no longer valid", func(t *testing.T) { i := testhelpers.IntrospectToken(t, token.AccessToken, adminTS) assert.False(t, i.Get("active").Bool(), "%s", i) }) t.Run("followup=original refresh token is no longer valid", func(t *testing.T) { _, err := conf.TokenSource(context.Background(), token).Token() assert.Error(t, err) }) t.Run("followup=but fail subsequent refresh because expiry was reached", func(t *testing.T) { waitForRefreshTokenExpiry() // Force golang to refresh token refreshedToken.Expiry = refreshedToken.Expiry.Add(-time.Hour * 24) _, err := conf.TokenSource(context.Background(), refreshedToken).Token() require.Error(t, err) }) }) } t.Run("strategy=jwt", func(t *testing.T) { reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "jwt") run(t, "jwt") }) t.Run("strategy=opaque", func(t *testing.T) { reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque") run(t, "opaque") }) }) t.Run("case=perform flow with audience", func(t *testing.T) { expectAud := "https://api.ory.sh/" c, conf := newDeviceClient(t, reg) testhelpers.NewDeviceLoginConsentUI( t, reg.Config(), acceptDeviceHandler(t, c), acceptLoginHandler(t, c, subject, conf.Scopes, func(r *hydra.OAuth2LoginRequest) *hydra.AcceptOAuth2LoginRequest { assert.False(t, r.Skip) assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) return nil }), acceptConsentHandler(t, c, subject, conf.Scopes, func(r *hydra.OAuth2ConsentRequest) { assert.False(t, *r.Skip) assert.EqualValues(t, []string{expectAud}, r.RequestedAccessTokenAudience) }), ) resp, err := conf.DeviceAuth(ctx, oauth2.SetAuthURLParam("audience", "https://api.ory.sh/")) require.NoError(t, err) require.NotEmpty(t, resp.DeviceCode) require.NotEmpty(t, resp.UserCode) loginFlowResp := acceptUserCode(t, conf, nil, resp) require.NotNil(t, loginFlowResp) token, err := conf.DeviceAccessToken(context.Background(), resp) require.NoError(t, err) claims := introspectAccessToken(t, conf, token, subject) aud := claims.Get("aud").Array() require.Len(t, aud, 1) assert.EqualValues(t, aud[0].String(), expectAud) assertIDToken(t, token, conf, subject, nonce, time.Now().Add(reg.Config().GetIDTokenLifespan(ctx))) }) t.Run("case=respects client token lifespan configuration", func(t *testing.T) { run := func(t *testing.T, strategy string, c *client.Client, conf *oauth2.Config, expectedLifespans client.Lifespans) { testhelpers.NewDeviceLoginConsentUI( t, reg.Config(), acceptDeviceHandler(t, c), acceptLoginHandler(t, c, subject, conf.Scopes, nil), acceptConsentHandler(t, c, subject, conf.Scopes, nil), ) resp, err := getDeviceCode(t, conf, nil) require.NoError(t, err) require.NotEmpty(t, resp.DeviceCode) require.NotEmpty(t, resp.UserCode) loginFlowResp := acceptUserCode(t, conf, nil, resp) require.NotNil(t, loginFlowResp) token, err := conf.DeviceAccessToken(context.Background(), resp) iat := time.Now() require.NoError(t, err) body := introspectAccessToken(t, conf, token, subject) assert.WithinDuration(t, iat.Add(expectedLifespans.DeviceAuthorizationGrantAccessTokenLifespan.Duration), time.Unix(body.Get("exp").Int(), 0), time.Second) assertJWTAccessToken(t, strategy, conf, token, subject, iat.Add(expectedLifespans.DeviceAuthorizationGrantAccessTokenLifespan.Duration), `["hydra","offline","openid"]`) assertIDToken(t, token, conf, subject, nonce, iat.Add(expectedLifespans.DeviceAuthorizationGrantIDTokenLifespan.Duration)) assertRefreshToken(t, token, conf, iat.Add(expectedLifespans.DeviceAuthorizationGrantRefreshTokenLifespan.Duration)) t.Run("followup=successfully perform refresh token flow", func(t *testing.T) { require.NotEmpty(t, token.RefreshToken) token.Expiry = token.Expiry.Add(-time.Hour * 24) refreshedToken, err := conf.TokenSource(context.Background(), token).Token() iat = time.Now() require.NoError(t, err) assertRefreshToken(t, refreshedToken, conf, iat.Add(expectedLifespans.RefreshTokenGrantRefreshTokenLifespan.Duration)) assertJWTAccessToken(t, strategy, conf, refreshedToken, subject, iat.Add(expectedLifespans.RefreshTokenGrantAccessTokenLifespan.Duration), `["hydra","offline","openid"]`) assertIDToken(t, refreshedToken, conf, subject, nonce, iat.Add(expectedLifespans.RefreshTokenGrantIDTokenLifespan.Duration)) require.NotEqual(t, token.AccessToken, refreshedToken.AccessToken) require.NotEqual(t, token.RefreshToken, refreshedToken.RefreshToken) require.NotEqual(t, token.Extra("id_token"), refreshedToken.Extra("id_token")) body := introspectAccessToken(t, conf, refreshedToken, subject) assert.WithinDuration(t, iat.Add(expectedLifespans.RefreshTokenGrantAccessTokenLifespan.Duration), time.Unix(body.Get("exp").Int(), 0), time.Second) t.Run("followup=original access token is no longer valid", func(t *testing.T) { i := testhelpers.IntrospectToken(t, token.AccessToken, adminTS) assert.False(t, i.Get("active").Bool(), "%s", i) }) t.Run("followup=original refresh token is no longer valid", func(t *testing.T) { _, err := conf.TokenSource(context.Background(), token).Token() assert.Error(t, err) }) }) } t.Run("case=custom-lifespans-active-jwt", func(t *testing.T) { c, conf := newDeviceClient(t, reg) ls := testhelpers.TestLifespans ls.DeviceAuthorizationGrantAccessTokenLifespan = x.NullDuration{Valid: true, Duration: 6 * time.Second} testhelpers.UpdateClientTokenLifespans( t, &oauth2.Config{ClientID: c.GetID(), ClientSecret: conf.ClientSecret}, c.GetID(), ls, adminTS, ) reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "jwt") run(t, "jwt", c, conf, ls) }) t.Run("case=custom-lifespans-active-opaque", func(t *testing.T) { c, conf := newDeviceClient(t, reg) ls := testhelpers.TestLifespans ls.DeviceAuthorizationGrantAccessTokenLifespan = x.NullDuration{Valid: true, Duration: 6 * time.Second} testhelpers.UpdateClientTokenLifespans( t, &oauth2.Config{ClientID: c.GetID(), ClientSecret: conf.ClientSecret}, c.GetID(), ls, adminTS, ) reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque") run(t, "opaque", c, conf, ls) }) t.Run("case=custom-lifespans-unset", func(t *testing.T) { c, conf := newDeviceClient(t, reg) testhelpers.UpdateClientTokenLifespans(t, &oauth2.Config{ClientID: c.GetID(), ClientSecret: conf.ClientSecret}, c.GetID(), testhelpers.TestLifespans, adminTS) testhelpers.UpdateClientTokenLifespans(t, &oauth2.Config{ClientID: c.GetID(), ClientSecret: conf.ClientSecret}, c.GetID(), client.Lifespans{}, adminTS) reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque") //goland:noinspection GoDeprecation expectedLifespans := client.Lifespans{ AuthorizationCodeGrantAccessTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetAccessTokenLifespan(ctx)}, AuthorizationCodeGrantIDTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetIDTokenLifespan(ctx)}, AuthorizationCodeGrantRefreshTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetRefreshTokenLifespan(ctx)}, ClientCredentialsGrantAccessTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetAccessTokenLifespan(ctx)}, ImplicitGrantAccessTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetAccessTokenLifespan(ctx)}, ImplicitGrantIDTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetIDTokenLifespan(ctx)}, JwtBearerGrantAccessTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetAccessTokenLifespan(ctx)}, PasswordGrantAccessTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetAccessTokenLifespan(ctx)}, PasswordGrantRefreshTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetRefreshTokenLifespan(ctx)}, RefreshTokenGrantIDTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetIDTokenLifespan(ctx)}, RefreshTokenGrantAccessTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetAccessTokenLifespan(ctx)}, RefreshTokenGrantRefreshTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetRefreshTokenLifespan(ctx)}, DeviceAuthorizationGrantIDTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetIDTokenLifespan(ctx)}, DeviceAuthorizationGrantAccessTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetAccessTokenLifespan(ctx)}, DeviceAuthorizationGrantRefreshTokenLifespan: x.NullDuration{Valid: true, Duration: reg.Config().GetRefreshTokenLifespan(ctx)}, } run(t, "opaque", c, conf, expectedLifespans) }) }) t.Run("case=cannot reuse user_code", func(t *testing.T) { c, conf := newDeviceClient(t, reg) testhelpers.NewDeviceLoginConsentUI(t, reg.Config(), func(w http.ResponseWriter, r *http.Request) { userCode := r.URL.Query().Get("user_code") payload := hydra.AcceptDeviceUserCodeRequest{ UserCode: &userCode, } v, _, err := adminClient.OAuth2API.AcceptUserCodeRequest(context.Background()). DeviceChallenge(r.URL.Query().Get("device_challenge")). AcceptDeviceUserCodeRequest(payload). Execute() if err != nil { w.WriteHeader(http.StatusBadRequest) return } require.NotEmpty(t, v.RedirectTo) http.Redirect(w, r, v.RedirectTo, http.StatusFound) }, acceptLoginHandler(t, c, subject, conf.Scopes, nil), acceptConsentHandler(t, c, subject, conf.Scopes, nil), ) resp, err := getDeviceCode(t, conf, nil) require.NoError(t, err) require.NotEmpty(t, resp.DeviceCode) require.NotEmpty(t, resp.UserCode) loginFlowResp := acceptUserCode(t, conf, nil, resp) require.NotNil(t, loginFlowResp) token, err := conf.DeviceAccessToken(context.Background(), resp) iat := time.Now() require.NoError(t, err) introspectAccessToken(t, conf, token, subject) assertIDToken(t, token, conf, subject, nonce, iat.Add(reg.Config().GetIDTokenLifespan(ctx))) assertRefreshToken(t, token, conf, iat.Add(reg.Config().GetRefreshTokenLifespan(ctx))) hc := testhelpers.NewEmptyJarClient(t) loginFlowResp2, err := hc.Get(resp.VerificationURIComplete) require.NoError(t, err) require.Equal(t, loginFlowResp2.StatusCode, http.StatusBadRequest) }) t.Run("case=cannot reuse device_challenge", func(t *testing.T) { var deviceChallenge string c, conf := newDeviceClient(t, reg) testhelpers.NewDeviceLoginConsentUI(t, reg.Config(), func(w http.ResponseWriter, r *http.Request) { userCode := r.URL.Query().Get("user_code") payload := hydra.AcceptDeviceUserCodeRequest{ UserCode: &userCode, } if deviceChallenge == "" { deviceChallenge = r.URL.Query().Get("device_challenge") } v, _, err := adminClient.OAuth2API.AcceptUserCodeRequest(context.Background()). DeviceChallenge(deviceChallenge). AcceptDeviceUserCodeRequest(payload). Execute() if err != nil { w.WriteHeader(http.StatusBadRequest) return } require.NoError(t, err) require.NotEmpty(t, v.RedirectTo) http.Redirect(w, r, v.RedirectTo, http.StatusFound) }, acceptLoginHandler(t, c, subject, conf.Scopes, nil), acceptConsentHandler(t, c, subject, conf.Scopes, nil), ) resp, err := getDeviceCode(t, conf, nil) require.NoError(t, err) require.NotEmpty(t, resp.DeviceCode) require.NotEmpty(t, resp.UserCode) hc := testhelpers.NewEmptyJarClient(t) loginFlowResp := acceptUserCode(t, conf, hc, resp) require.NoError(t, err) require.Contains(t, reg.Config().DeviceDoneURL(ctx).String(), loginFlowResp.Request.URL.Path, "did not end up in post device URL") require.Equal(t, loginFlowResp.Request.URL.Query().Get("client_id"), conf.ClientID) require.NotNil(t, loginFlowResp) token, err := conf.DeviceAccessToken(context.Background(), resp) iat := time.Now() require.NoError(t, err) introspectAccessToken(t, conf, token, subject) assertIDToken(t, token, conf, subject, nonce, iat.Add(reg.Config().GetIDTokenLifespan(ctx))) assertRefreshToken(t, token, conf, iat.Add(reg.Config().GetRefreshTokenLifespan(ctx))) resp2, err := getDeviceCode(t, conf, nil) require.NoError(t, err) require.NotEmpty(t, resp2.DeviceCode) require.NotEmpty(t, resp2.UserCode) payload := hydra.AcceptDeviceUserCodeRequest{ UserCode: &resp2.UserCode, } acceptResp, _, err := adminClient.OAuth2API.AcceptUserCodeRequest(context.Background()). DeviceChallenge(deviceChallenge). AcceptDeviceUserCodeRequest(payload). Execute() require.NoError(t, err) loginFlowResp2, err := hc.Get(acceptResp.RedirectTo) require.NoError(t, err) require.Equalf(t, http.StatusForbidden, loginFlowResp2.StatusCode, "requested %q", acceptResp.RedirectTo) }) } func newDeviceClient( t *testing.T, reg interface { config.Provider client.Registry }, opts ...func(*client.Client), ) (*client.Client, *oauth2.Config) { ctx := context.Background() c := &client.Client{ GrantTypes: []string{ "refresh_token", "urn:ietf:params:oauth:grant-type:device_code", }, Scope: "hydra offline openid", Audience: []string{"https://api.ory.sh/"}, TokenEndpointAuthMethod: "none", } // apply options for _, o := range opts { o(c) } require.NoError(t, reg.ClientManager().CreateClient(ctx, c)) return c, &oauth2.Config{ ClientID: c.GetID(), Endpoint: oauth2.Endpoint{ DeviceAuthURL: reg.Config().OAuth2DeviceAuthorisationURL(ctx).String(), TokenURL: reg.Config().OAuth2TokenURL(ctx).String(), AuthStyle: oauth2.AuthStyleInHeader, }, Scopes: strings.Split(c.Scope, " "), } }