diff --git a/client/registry.go b/client/registry.go index d7e179a558c..700cd2bd006 100644 --- a/client/registry.go +++ b/client/registry.go @@ -8,6 +8,7 @@ import ( "github.com/ory/fosite" foauth2 "github.com/ory/fosite/handler/oauth2" + "github.com/ory/fosite/handler/rfc8628" "github.com/ory/hydra/v2/jwk" "github.com/ory/hydra/v2/x" ) @@ -23,5 +24,6 @@ type Registry interface { ClientHasher() fosite.Hasher OpenIDJWTStrategy() jwk.JWTSigner OAuth2HMACStrategy() *foauth2.HMACSHAStrategy + RFC8628HMACStrategy() rfc8628.RFC8628CodeStrategy config.Provider } diff --git a/consent/handler.go b/consent/handler.go index d0d3fd2aa2b..c52338a9048 100644 --- a/consent/handler.go +++ b/consent/handler.go @@ -37,6 +37,7 @@ const ( LoginPath = "/oauth2/auth/requests/login" ConsentPath = "/oauth2/auth/requests/consent" LogoutPath = "/oauth2/auth/requests/logout" + DevicePath = "/oauth2/auth/requests/device" SessionsPath = "/oauth2/auth/sessions" ) @@ -66,6 +67,8 @@ func (h *Handler) SetRoutes(admin *httprouterx.RouterAdmin) { admin.GET(LogoutPath, h.getOAuth2LogoutRequest) admin.PUT(LogoutPath+"/accept", h.acceptOAuth2LogoutRequest) admin.PUT(LogoutPath+"/reject", h.rejectOAuth2LogoutRequest) + + admin.PUT(DevicePath+"/verify", h.verifyUserCodeRequest) } // Revoke OAuth 2.0 Consent Session Parameters @@ -1037,3 +1040,90 @@ func (h *Handler) getOAuth2LogoutRequest(w http.ResponseWriter, r *http.Request, h.r.Writer().Write(w, r, request) } + +// Verify OAuth 2.0 User Code Request +// +// swagger:parameters verifyUserCodeRequest +type verifyUserCodeRequest struct { + // in: query + // required: true + Challenge string `json:"device_challenge"` + + // in: body + Body flow.DeviceGrantVerifyUserCodeRequest +} + +// swagger:route PUT /admin/oauth2/auth/requests/device/verify oAuth2 verifyUserCodeRequest +// +// # Verifies a device grant request +// +// Verifies a device grant request +// +// Consumes: +// - application/json +// +// Produces: +// - application/json +// +// Schemes: http, https +// +// Responses: +// 200: oAuth2RedirectTo +// default: errorOAuth2 +func (h *Handler) verifyUserCodeRequest(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + challenge := stringsx.Coalesce( + r.URL.Query().Get("device_challenge"), + r.URL.Query().Get("challenge"), + ) + if challenge == "" { + h.r.Writer().WriteError(w, r, errorsx.WithStack(fosite.ErrInvalidRequest.WithHint(`Query parameter 'challenge' is not defined but should have been.`))) + return + } + + var p flow.DeviceGrantVerifyUserCodeRequest + d := json.NewDecoder(r.Body) + d.DisallowUnknownFields() + if err := d.Decode(&p); err != nil { + h.r.Writer().WriteError(w, r, errorsx.WithStack(fosite.ErrInvalidRequest.WithWrap(err).WithHintf("Unable to decode body because: %s", err))) + return + } + + if p.UserCode == "" { + h.r.Writer().WriteError(w, r, errorsx.WithStack(fosite.ErrInvalidRequest.WithHint("Field 'user_code' must not be empty."))) + return + } + + userCodeSignature, err := h.r.RFC8628HMACStrategy().UserCodeSignature(r.Context(), p.UserCode) + if err != nil { + h.r.Writer().WriteError(w, r, errorsx.WithStack(fosite.ErrServerError.WithWrap(err).WithHint(`'user_code' signature could not be computed`))) + return + } + userCodeRequest, err := h.r.OAuth2Storage().GetUserCodeSession(r.Context(), userCodeSignature, &fosite.DefaultSession{}) + if err != nil { + h.r.Writer().WriteError(w, r, errorsx.WithStack(fosite.ErrNotFound.WithWrap(err).WithHint(`'user_code' session not found`))) + return + } + + clientId := userCodeRequest.GetClient().GetID() + // UserCode & DeviceCode Request shares the same RequestId as it's the same request; + deviceRequestId := userCodeRequest.GetID() + requestedScopes := userCodeRequest.GetRequestedScopes() + requestedAudience := userCodeRequest.GetRequestedAudience() + + err = h.r.OAuth2Storage().InvalidateUserCodeSession(r.Context(), userCodeSignature) + if err != nil { + h.r.Writer().WriteError(w, r, errorsx.WithStack(fosite.ErrServerError.WithWrap(err).WithHint(`Could not invalidate 'user_code'`))) + return + } + + // req.GetID() is actually the DeviceCodeSignature + grantRequest, err := h.r.ConsentManager().AcceptDeviceGrantRequest(r.Context(), challenge, deviceRequestId, clientId, requestedScopes, requestedAudience) + if err != nil { + h.r.Writer().WriteError(w, r, errorsx.WithStack(fosite.ErrServerError.WithWrap(err).WithHint(`Could not accept device grant request`))) + return + } + + h.r.Writer().Write(w, r, &flow.OAuth2RedirectTo{ + RedirectTo: urlx.SetQuery(h.c.OAuth2DeviceAuthorisationURL(r.Context()), url.Values{"device_verifier": {grantRequest.Verifier}, "client_id": {clientId}}).String(), + }) +} diff --git a/consent/helper.go b/consent/helper.go index 362f2952284..77b9db24a1c 100644 --- a/consent/helper.go +++ b/consent/helper.go @@ -9,7 +9,7 @@ import ( "github.com/ory/hydra/v2/flow" ) -func sanitizeClientFromRequest(ar fosite.AuthorizeRequester) *client.Client { +func sanitizeClientFromRequest(ar fosite.Requester) *client.Client { return sanitizeClient(ar.GetClient().(*client.Client)) } diff --git a/consent/manager.go b/consent/manager.go index fe4b018352e..40cb566bdfa 100644 --- a/consent/manager.go +++ b/consent/manager.go @@ -8,6 +8,7 @@ import ( "github.com/gofrs/uuid" + "github.com/ory/fosite" "github.com/ory/hydra/v2/client" "github.com/ory/hydra/v2/flow" ) @@ -60,6 +61,11 @@ type ( AcceptLogoutRequest(ctx context.Context, challenge string) (*flow.LogoutRequest, error) RejectLogoutRequest(ctx context.Context, challenge string) error VerifyAndInvalidateLogoutRequest(ctx context.Context, verifier string) (*flow.LogoutRequest, error) + + CreateDeviceGrantRequest(ctx context.Context, req *flow.DeviceGrantRequest) error + GetDeviceGrantRequestByVerifier(ctx context.Context, verifier string) (*flow.DeviceGrantRequest, error) + AcceptDeviceGrantRequest(ctx context.Context, challenge string, device_code_signature string, clientId string, requested_scopes fosite.Arguments, requested_aud fosite.Arguments) (*flow.DeviceGrantRequest, error) + VerifyAndInvalidateDeviceGrantRequest(ctx context.Context, verifier string) (*flow.DeviceGrantRequest, error) } ManagerProvider interface { diff --git a/consent/strategy.go b/consent/strategy.go index 08e8788c756..f0027a36744 100644 --- a/consent/strategy.go +++ b/consent/strategy.go @@ -20,6 +20,12 @@ type Strategy interface { r *http.Request, req fosite.AuthorizeRequester, ) (*flow.AcceptOAuth2ConsentRequest, *flow.Flow, error) + HandleOAuth2DeviceAuthorizationRequest( + ctx context.Context, + w http.ResponseWriter, + r *http.Request, + req fosite.DeviceUserRequester, + ) (*flow.AcceptOAuth2ConsentRequest, *flow.Flow, error) HandleOpenIDConnectLogout(ctx context.Context, w http.ResponseWriter, r *http.Request) (*flow.LogoutResult, error) HandleHeadlessLogout(ctx context.Context, w http.ResponseWriter, r *http.Request, sid string) error ObfuscateSubjectIdentifier(ctx context.Context, cl fosite.Client, subject, forcedIdentifier string) (string, error) diff --git a/consent/strategy_default.go b/consent/strategy_default.go index 28fba843443..15ac2e240c7 100644 --- a/consent/strategy_default.go +++ b/consent/strategy_default.go @@ -120,24 +120,24 @@ func (s *DefaultStrategy) authenticationSession(ctx context.Context, _ http.Resp return session, nil } -func (s *DefaultStrategy) requestAuthentication(ctx context.Context, w http.ResponseWriter, r *http.Request, ar fosite.AuthorizeRequester) (err error) { +func (s *DefaultStrategy) requestAuthentication(ctx context.Context, w http.ResponseWriter, r *http.Request, req fosite.Requester) (err error) { ctx, span := trace.SpanFromContext(ctx).TracerProvider().Tracer("").Start(ctx, "DefaultStrategy.requestAuthentication") defer otelx.End(span, &err) - prompt := stringsx.Splitx(ar.GetRequestForm().Get("prompt"), " ") + prompt := stringsx.Splitx(req.GetRequestForm().Get("prompt"), " ") if stringslice.Has(prompt, "login") { - return s.forwardAuthenticationRequest(ctx, w, r, ar, "", time.Time{}, nil) + return s.forwardAuthenticationRequest(ctx, w, r, req, "", time.Time{}, nil) } session, err := s.authenticationSession(ctx, w, r) if errors.Is(err, ErrNoAuthenticationSessionFound) { - return s.forwardAuthenticationRequest(ctx, w, r, ar, "", time.Time{}, nil) + return s.forwardAuthenticationRequest(ctx, w, r, req, "", time.Time{}, nil) } else if err != nil { return err } maxAge := int64(-1) - if ma := ar.GetRequestForm().Get("max_age"); len(ma) > 0 { + if ma := req.GetRequestForm().Get("max_age"); len(ma) > 0 { var err error maxAge, err = strconv.ParseInt(ma, 10, 64) if err != nil { @@ -149,12 +149,12 @@ func (s *DefaultStrategy) requestAuthentication(ctx context.Context, w http.Resp if stringslice.Has(prompt, "none") { return errorsx.WithStack(fosite.ErrLoginRequired.WithHint("Request failed because prompt is set to 'none' and authentication time reached 'max_age'.")) } - return s.forwardAuthenticationRequest(ctx, w, r, ar, "", time.Time{}, nil) + return s.forwardAuthenticationRequest(ctx, w, r, req, "", time.Time{}, nil) } - idTokenHint := ar.GetRequestForm().Get("id_token_hint") + idTokenHint := req.GetRequestForm().Get("id_token_hint") if idTokenHint == "" { - return s.forwardAuthenticationRequest(ctx, w, r, ar, session.Subject, time.Time(session.AuthenticatedAt), session) + return s.forwardAuthenticationRequest(ctx, w, r, req, session.Subject, time.Time(session.AuthenticatedAt), session) } hintSub, err := s.getSubjectFromIDTokenHint(r.Context(), idTokenHint) @@ -162,11 +162,11 @@ func (s *DefaultStrategy) requestAuthentication(ctx context.Context, w http.Resp return err } - if err := s.matchesValueFromSession(r.Context(), ar.GetClient(), hintSub, session.Subject); errors.Is(err, ErrHintDoesNotMatchAuthentication) { + if err := s.matchesValueFromSession(r.Context(), req.GetClient(), hintSub, session.Subject); errors.Is(err, ErrHintDoesNotMatchAuthentication) { return errorsx.WithStack(fosite.ErrLoginRequired.WithHint("Request failed because subject claim from id_token_hint does not match subject from authentication session.")) } - return s.forwardAuthenticationRequest(ctx, w, r, ar, session.Subject, time.Time(session.AuthenticatedAt), session) + return s.forwardAuthenticationRequest(ctx, w, r, req, session.Subject, time.Time(session.AuthenticatedAt), session) } func (s *DefaultStrategy) getIDTokenHintClaims(ctx context.Context, idTokenHint string) (jwt.MapClaims, error) { @@ -193,7 +193,7 @@ func (s *DefaultStrategy) getSubjectFromIDTokenHint(ctx context.Context, idToken return sub, nil } -func (s *DefaultStrategy) forwardAuthenticationRequest(ctx context.Context, w http.ResponseWriter, r *http.Request, ar fosite.AuthorizeRequester, subject string, authenticatedAt time.Time, session *flow.LoginSession) error { +func (s *DefaultStrategy) forwardAuthenticationRequest(ctx context.Context, w http.ResponseWriter, r *http.Request, req fosite.Requester, subject string, authenticatedAt time.Time, session *flow.LoginSession) error { if (subject != "" && authenticatedAt.IsZero()) || (subject == "" && !authenticatedAt.IsZero()) { return errorsx.WithStack(fosite.ErrServerError.WithHint("Consent strategy returned a non-empty subject with an empty auth date, or an empty subject with a non-empty auth date.")) } @@ -204,7 +204,7 @@ func (s *DefaultStrategy) forwardAuthenticationRequest(ctx context.Context, w ht } // Let's validate that prompt is actually not "none" if we can't skip authentication - prompt := stringsx.Splitx(ar.GetRequestForm().Get("prompt"), " ") + prompt := stringsx.Splitx(req.GetRequestForm().Get("prompt"), " ") if stringslice.Has(prompt, "none") && !skip { return errorsx.WithStack(fosite.ErrLoginRequired.WithHint(`Prompt 'none' was requested, but no existing login session was found.`)) } @@ -216,10 +216,13 @@ func (s *DefaultStrategy) forwardAuthenticationRequest(ctx context.Context, w ht // Generate the request URL iu := s.c.OAuth2AuthURL(ctx) + if _, ok := req.(fosite.DeviceUserRequester); ok { + iu = s.c.OAuth2DeviceAuthorisationURL(ctx) + } iu.RawQuery = r.URL.RawQuery var idTokenHintClaims jwt.MapClaims - if idTokenHint := ar.GetRequestForm().Get("id_token_hint"); len(idTokenHint) > 0 { + if idTokenHint := req.GetRequestForm().Get("id_token_hint"); len(idTokenHint) > 0 { claims, err := s.getIDTokenHintClaims(r.Context(), idTokenHint) if err != nil { return err @@ -234,14 +237,14 @@ func (s *DefaultStrategy) forwardAuthenticationRequest(ctx context.Context, w ht } // Set the session - cl := sanitizeClientFromRequest(ar) + cl := sanitizeClientFromRequest(req) loginRequest := &flow.LoginRequest{ ID: challenge, Verifier: verifier, CSRF: csrf, Skip: skip, - RequestedScope: []string(ar.GetRequestedScopes()), - RequestedAudience: []string(ar.GetRequestedAudience()), + RequestedScope: []string(req.GetRequestedScopes()), + RequestedAudience: []string(req.GetRequestedAudience()), Subject: subject, Client: cl, RequestURL: iu.String(), @@ -250,10 +253,10 @@ func (s *DefaultStrategy) forwardAuthenticationRequest(ctx context.Context, w ht SessionID: sqlxx.NullString(sessionID), OpenIDConnectContext: &flow.OAuth2ConsentRequestOpenIDConnectContext{ IDTokenHintClaims: idTokenHintClaims, - ACRValues: stringsx.Splitx(ar.GetRequestForm().Get("acr_values"), " "), - UILocales: stringsx.Splitx(ar.GetRequestForm().Get("ui_locales"), " "), - Display: ar.GetRequestForm().Get("display"), - LoginHint: ar.GetRequestForm().Get("login_hint"), + ACRValues: stringsx.Splitx(req.GetRequestForm().Get("acr_values"), " "), + UILocales: stringsx.Splitx(req.GetRequestForm().Get("ui_locales"), " "), + Display: req.GetRequestForm().Get("display"), + LoginHint: req.GetRequestForm().Get("login_hint"), }, } f, err := s.r.ConsentManager().CreateLoginRequest( @@ -336,7 +339,7 @@ func (s *DefaultStrategy) verifyAuthentication( ctx context.Context, w http.ResponseWriter, r *http.Request, - req fosite.AuthorizeRequester, + req fosite.Requester, verifier string, ) (_ *flow.Flow, err error) { ctx, span := trace.SpanFromContext(ctx).TracerProvider().Tracer("").Start(ctx, "DefaultStrategy.verifyAuthentication") @@ -394,33 +397,64 @@ func (s *DefaultStrategy) verifyAuthentication( sessionID := session.LoginRequest.SessionID.String() - if err := s.r.OpenIDConnectRequestValidator().ValidatePrompt(ctx, &fosite.AuthorizeRequest{ - ResponseTypes: req.GetResponseTypes(), - RedirectURI: req.GetRedirectURI(), - State: req.GetState(), - // HandledResponseTypes, this can be safely ignored because it's not being used by validation - Request: fosite.Request{ - ID: req.GetID(), - RequestedAt: req.GetRequestedAt(), - Client: req.GetClient(), - RequestedAudience: req.GetRequestedAudience(), - GrantedAudience: req.GetGrantedAudience(), - RequestedScope: req.GetRequestedScopes(), - GrantedScope: req.GetGrantedScopes(), - Form: req.GetRequestForm(), - Session: &openid.DefaultSession{ - Claims: &jwt.IDTokenClaims{ - Subject: subjectIdentifier, - IssuedAt: time.Now().UTC(), // doesn't matter - ExpiresAt: time.Now().Add(time.Hour).UTC(), // doesn't matter - AuthTime: time.Time(session.AuthenticatedAt), - RequestedAt: session.RequestedAt, + var cleanReq fosite.Requester + if ar, ok := req.(fosite.AuthorizeRequester); ok { + cleanReq = &fosite.AuthorizeRequest{ + ResponseTypes: ar.GetResponseTypes(), + RedirectURI: ar.GetRedirectURI(), + State: ar.GetState(), + // HandledResponseTypes, this can be safely ignored because it's not being used by validation + Request: fosite.Request{ + ID: req.GetID(), + RequestedAt: req.GetRequestedAt(), + Client: req.GetClient(), + RequestedAudience: req.GetRequestedAudience(), + GrantedAudience: req.GetGrantedAudience(), + RequestedScope: req.GetRequestedScopes(), + GrantedScope: req.GetGrantedScopes(), + Form: req.GetRequestForm(), + Session: &openid.DefaultSession{ + Claims: &jwt.IDTokenClaims{ + Subject: subjectIdentifier, + IssuedAt: time.Now().UTC(), // doesn't matter + ExpiresAt: time.Now().Add(time.Hour).UTC(), // doesn't matter + AuthTime: time.Time(session.AuthenticatedAt), + RequestedAt: session.RequestedAt, + }, + Headers: &jwt.Headers{}, + Subject: session.Subject, }, - Headers: &jwt.Headers{}, - Subject: session.Subject, }, - }, - }); errors.Is(err, fosite.ErrLoginRequired) { + } + } else if _, ok := req.(fosite.DeviceUserRequester); ok { + cleanReq = &fosite.DeviceUserRequest{ + Request: fosite.Request{ + ID: req.GetID(), + RequestedAt: req.GetRequestedAt(), + Client: req.GetClient(), + RequestedAudience: req.GetRequestedAudience(), + GrantedAudience: req.GetGrantedAudience(), + RequestedScope: req.GetRequestedScopes(), + GrantedScope: req.GetGrantedScopes(), + Form: req.GetRequestForm(), + Session: &openid.DefaultSession{ + Claims: &jwt.IDTokenClaims{ + Subject: subjectIdentifier, + IssuedAt: time.Now().UTC(), // doesn't matter + ExpiresAt: time.Now().Add(time.Hour).UTC(), // doesn't matter + AuthTime: time.Time(session.AuthenticatedAt), + RequestedAt: session.RequestedAt, + }, + Headers: &jwt.Headers{}, + Subject: session.Subject, + }, + }, + } + } else { + return nil, errorsx.WithStack(fosite.ErrServerError.WithHint("Could not determine the Requester type")) + } + + if err := s.r.OpenIDConnectRequestValidator().ValidatePrompt(ctx, cleanReq); errors.Is(err, fosite.ErrLoginRequired) { // This indicates that something went wrong with checking the subject id - let's destroy the session to be safe if err := s.revokeAuthenticationSession(ctx, w, r); err != nil { return nil, err @@ -506,15 +540,15 @@ func (s *DefaultStrategy) requestConsent( ctx context.Context, w http.ResponseWriter, r *http.Request, - ar fosite.AuthorizeRequester, + req fosite.Requester, f *flow.Flow, ) (err error) { ctx, span := trace.SpanFromContext(ctx).TracerProvider().Tracer("").Start(ctx, "DefaultStrategy.requestConsent") defer otelx.End(span, &err) - prompt := stringsx.Splitx(ar.GetRequestForm().Get("prompt"), " ") + prompt := stringsx.Splitx(req.GetRequestForm().Get("prompt"), " ") if stringslice.Has(prompt, "consent") { - return s.forwardConsentRequest(ctx, w, r, ar, f, nil) + return s.forwardConsentRequest(ctx, w, r, req, f, nil) } // https://tools.ietf.org/html/rfc6749 @@ -531,14 +565,16 @@ func (s *DefaultStrategy) requestConsent( // authorization servers as identity proof. Some operating systems may // offer alternative platform-specific identity features that MAY be // accepted, as appropriate. - if ar.GetClient().IsPublic() { + if req.GetClient().IsPublic() { // The OpenID Connect Test Tool fails if this returns `consent_required` when `prompt=none` is used. // According to the quote above, it should be ok to allow https to skip consent. // // This is tracked as issue: https://github.com/ory/hydra/issues/866 // This is also tracked as upstream issue: https://github.com/openid-certification/oidctest/issues/97 - if !(ar.GetRedirectURI().Scheme == "https" || (fosite.IsLocalhost(ar.GetRedirectURI()) && ar.GetRedirectURI().Scheme == "http")) { - return s.forwardConsentRequest(ctx, w, r, ar, f, nil) + if ar, ok := req.(fosite.AuthorizeRequester); ok { + if !(ar.GetRedirectURI().Scheme == "https" || (fosite.IsLocalhost(ar.GetRedirectURI()) && ar.GetRedirectURI().Scheme == "http")) { + return s.forwardConsentRequest(ctx, w, r, ar, f, nil) + } } } @@ -549,25 +585,25 @@ func (s *DefaultStrategy) requestConsent( // return s.forwardConsentRequest(w, r, ar, authenticationSession, nil) // } - consentSessions, err := s.r.ConsentManager().FindGrantedAndRememberedConsentRequests(ctx, ar.GetClient().GetID(), f.Subject) + consentSessions, err := s.r.ConsentManager().FindGrantedAndRememberedConsentRequests(ctx, req.GetClient().GetID(), f.Subject) if errors.Is(err, ErrNoPreviousConsentFound) { - return s.forwardConsentRequest(ctx, w, r, ar, f, nil) + return s.forwardConsentRequest(ctx, w, r, req, f, nil) } else if err != nil { return err } - if found := matchScopes(s.r.Config().GetScopeStrategy(ctx), consentSessions, ar.GetRequestedScopes()); found != nil { - return s.forwardConsentRequest(ctx, w, r, ar, f, found) + if found := matchScopes(s.r.Config().GetScopeStrategy(ctx), consentSessions, req.GetRequestedScopes()); found != nil { + return s.forwardConsentRequest(ctx, w, r, req, f, found) } - return s.forwardConsentRequest(ctx, w, r, ar, f, nil) + return s.forwardConsentRequest(ctx, w, r, req, f, nil) } func (s *DefaultStrategy) forwardConsentRequest( ctx context.Context, w http.ResponseWriter, r *http.Request, - ar fosite.AuthorizeRequester, + req fosite.Requester, f *flow.Flow, previousConsent *flow.AcceptOAuth2ConsentRequest, ) error { @@ -577,7 +613,7 @@ func (s *DefaultStrategy) forwardConsentRequest( skip = true } - prompt := stringsx.Splitx(ar.GetRequestForm().Get("prompt"), " ") + prompt := stringsx.Splitx(req.GetRequestForm().Get("prompt"), " ") if stringslice.Has(prompt, "none") && !skip { return errorsx.WithStack(fosite.ErrConsentRequired.WithHint(`Prompt 'none' was requested, but no previous consent was found.`)) } @@ -587,7 +623,7 @@ func (s *DefaultStrategy) forwardConsentRequest( challenge := strings.Replace(uuid.New(), "-", "", -1) csrf := strings.Replace(uuid.New(), "-", "", -1) - cl := sanitizeClientFromRequest(ar) + cl := sanitizeClientFromRequest(req) consentRequest := &flow.OAuth2ConsentRequest{ ID: challenge, @@ -596,8 +632,8 @@ func (s *DefaultStrategy) forwardConsentRequest( Verifier: verifier, CSRF: csrf, Skip: skip, - RequestedScope: []string(ar.GetRequestedScopes()), - RequestedAudience: []string(ar.GetRequestedAudience()), + RequestedScope: []string(req.GetRequestedScopes()), + RequestedAudience: []string(req.GetRequestedAudience()), Subject: as.Subject, Client: cl, RequestURL: as.LoginRequest.RequestURL, @@ -1178,3 +1214,136 @@ func (s *DefaultStrategy) loginSessionFromCookie(r *http.Request) *flow.LoginSes return ls } + +func (s *DefaultStrategy) requestDevice(ctx context.Context, w http.ResponseWriter, r *http.Request, req fosite.Requester) error { + return s.forwardDeviceRequest(ctx, w, r, req) +} + +func (s *DefaultStrategy) forwardDeviceRequest(ctx context.Context, w http.ResponseWriter, r *http.Request, req fosite.Requester) error { + // Set up csrf/challenge/verifier values + verifier := strings.Replace(uuid.New(), "-", "", -1) + challenge := strings.Replace(uuid.New(), "-", "", -1) + csrf := strings.Replace(uuid.New(), "-", "", -1) + + // Generate the request URL + iu := s.c.OAuth2DeviceAuthorisationURL(ctx) + iu.RawQuery = r.URL.RawQuery + + if err := s.r.ConsentManager().CreateDeviceGrantRequest( + r.Context(), + &flow.DeviceGrantRequest{ + ID: challenge, + Verifier: verifier, + CSRF: csrf, + RequestURL: iu.String(), + }, + ); err != nil { + return errorsx.WithStack(err) + } + + store, err := s.r.CookieStore(ctx) + if err != nil { + return err + } + + if err := createCsrfSession(w, r, s.r.Config(), store, s.r.Config().CookieNameDeviceVerifyCSRF(ctx), csrf, s.c.ConsentRequestMaxAge(ctx)); err != nil { + return errorsx.WithStack(err) + } + + query := url.Values{"device_challenge": {challenge}} + if r.URL.Query().Has("user_code") { + query.Add("user_code", r.URL.Query().Get("user_code")) + } + + http.Redirect(w, r, urlx.SetQuery(s.c.DeviceUrl(ctx), query).String(), http.StatusFound) + + // generate the verifier + return errorsx.WithStack(ErrAbortOAuth2Request) +} + +func (s *DefaultStrategy) verifyDevice(ctx context.Context, w http.ResponseWriter, r *http.Request, req fosite.DeviceUserRequester, verifier string) (*flow.DeviceGrantRequest, error) { + session, err := s.r.ConsentManager().GetDeviceGrantRequestByVerifier(ctx, verifier) + if errors.Is(err, sqlcon.ErrNoRows) { + return nil, errorsx.WithStack(fosite.ErrAccessDenied.WithHint("The device verifier has already been used, has not been granted, or is invalid.")) + } else if err != nil { + return nil, err + } + + if session.Client.GetID() != req.GetClient().GetID() { + return nil, errorsx.WithStack(fosite.ErrInvalidGrant.WithHint("The OAuth 2.0 Client ID from this request does not match the one from the authorize request.")) + } + + if time.Time(session.AcceptedAt).Add(s.c.ConsentRequestMaxAge(ctx)).Before(time.Now()) { + return nil, errorsx.WithStack(fosite.ErrRequestUnauthorized.WithHint("The device request has expired. Please try again.")) + } + + store, err := s.r.CookieStore(ctx) + if err != nil { + return nil, err + } + + if err = validateCsrfSession(r, s.r.Config(), store, s.r.Config().CookieNameDeviceVerifyCSRF(ctx), session.CSRF); err != nil { + return nil, err + } + + return session, nil +} + +func (s *DefaultStrategy) invalidateDeviceRequest(ctx context.Context, w http.ResponseWriter, r *http.Request, req fosite.DeviceUserRequester, verifier string) (*flow.DeviceGrantRequest, error) { + session, err := s.r.ConsentManager().VerifyAndInvalidateDeviceGrantRequest(ctx, verifier) + if errors.Is(err, sqlcon.ErrNoRows) { + return nil, errorsx.WithStack(fosite.ErrAccessDenied.WithHint("The device verifier has already been used, has not been granted, or is invalid.")) + } else if err != nil { + return nil, err + } + + return session, nil +} + +func (s *DefaultStrategy) HandleOAuth2DeviceAuthorizationRequest( + ctx context.Context, + w http.ResponseWriter, + r *http.Request, + req fosite.DeviceUserRequester, +) (*flow.AcceptOAuth2ConsentRequest, *flow.Flow, error) { + loginVerifier := strings.TrimSpace(req.GetRequestForm().Get("login_verifier")) + consentVerifier := strings.TrimSpace(req.GetRequestForm().Get("consent_verifier")) + deviceVerifier := strings.TrimSpace(req.GetRequestForm().Get("device_verifier")) + + if deviceVerifier == "" && loginVerifier == "" && consentVerifier == "" { + // ok, we need to process this request and redirect to device auth endpoint + return nil, nil, s.requestDevice(ctx, w, r, req) + } else if loginVerifier == "" && consentVerifier == "" { + deviceSession, err := s.verifyDevice(ctx, w, r, req, deviceVerifier) + if err != nil { + return nil, nil, err + } + + // Set scope & audience requested by remote device; + req.SetRequestedScopes(fosite.Arguments(deviceSession.RequestedScope)) + req.SetRequestedAudience(fosite.Arguments(deviceSession.RequestedAudience)) + + return nil, nil, s.requestAuthentication(ctx, w, r, req) + } else if consentVerifier == "" { + f, err := s.verifyAuthentication(ctx, w, r, req, loginVerifier) + if err != nil { + return nil, nil, err + } + + // ok, we need to process this request and redirect to auth endpoint + return nil, f, s.requestConsent(ctx, w, r, req, f) + } + + deviceSession, err := s.invalidateDeviceRequest(ctx, w, r, req, deviceVerifier) + if err != nil { + return nil, nil, err + } + req.SetDeviceCodeSignature(deviceSession.DeviceCodeSignature.String()) + + consentSession, f, err := s.verifyConsent(ctx, w, r, consentVerifier) + if err != nil { + return nil, nil, err + } + + return consentSession, f, nil +} diff --git a/driver/config/provider.go b/driver/config/provider.go index ba1869498fe..bf2ceb625c4 100644 --- a/driver/config/provider.go +++ b/driver/config/provider.go @@ -49,6 +49,7 @@ const ( KeyOIDCDiscoverySupportedClaims = "webfinger.oidc_discovery.supported_claims" KeyOIDCDiscoverySupportedScope = "webfinger.oidc_discovery.supported_scope" KeyOIDCDiscoveryUserinfoEndpoint = "webfinger.oidc_discovery.userinfo_url" + KeyOAuth2DeviceAuthorisationURL = "webfinger.oidc_discovery.device_authorization_url" KeySubjectTypesSupported = "oidc.subject_identifiers.supported_types" KeyDefaultClientScope = "oidc.dynamic_client_registration.default_scope" KeyDSN = "dsn" @@ -64,6 +65,7 @@ const ( KeyCookieSecure = "serve.cookies.secure" KeyCookieLoginCSRFName = "serve.cookies.names.login_csrf" KeyCookieConsentCSRFName = "serve.cookies.names.consent_csrf" + KeyCookieDeviceVerifyCSRFName = "serve.cookies.names.consent_device_verify" KeyCookieSessionName = "serve.cookies.names.session" KeyCookieSessionPath = "serve.cookies.paths.session" KeyConsentRequestMaxAge = "ttl.login_consent_request" @@ -72,6 +74,7 @@ const ( KeyVerifiableCredentialsNonceLifespan = "ttl.vc_nonce" // #nosec G101 KeyIDTokenLifespan = "ttl.id_token" // #nosec G101 KeyAuthCodeLifespan = "ttl.auth_code" + KeyDeviceAndUserCodeLifespan = "ttl.device_user_code" // #nosec G101 KeyScopeStrategy = "strategies.scope" KeyGetCookieSecrets = "secrets.cookie" KeyGetSystemSecret = "secrets.system" @@ -81,9 +84,12 @@ const ( KeyLogoutURL = "urls.logout" KeyConsentURL = "urls.consent" KeyErrorURL = "urls.error" + KeyDeviceURL = "urls.device" + KeyDeviceDoneURL = "urls.post_device_done" KeyPublicURL = "urls.self.public" KeyAdminURL = "urls.self.admin" KeyIssuerURL = "urls.self.issuer" + KeyDeviceVerificationURL = "urls.self.device" KeyIdentityProviderAdminURL = "urls.identity_provider.url" KeyIdentityProviderPublicURL = "urls.identity_provider.publicUrl" KeyIdentityProviderHeaders = "urls.identity_provider.headers" @@ -92,6 +98,7 @@ const ( KeyDBIgnoreUnknownTableColumns = "db.ignore_unknown_table_columns" KeySubjectIdentifierAlgorithmSalt = "oidc.subject_identifiers.pairwise.salt" KeyPublicAllowDynamicRegistration = "oidc.dynamic_client_registration.enabled" + KeyDeviceAuthTokenPollingInterval = "oauth2.device_authorization.token_polling_interval" // #nosec G101 KeyPKCEEnforced = "oauth2.pkce.enforced" KeyPKCEEnforcedForPublicClients = "oauth2.pkce.enforced_for_public_clients" KeyLogLevel = "log.level" @@ -372,6 +379,14 @@ func (p *DefaultProvider) fallbackURL(ctx context.Context, path string, host str return &u } +func (p *DefaultProvider) GetDeviceAndUserCodeLifespan(ctx context.Context) time.Duration { + return p.p.DurationF(KeyDeviceAndUserCodeLifespan, time.Minute*15) +} + +func (p *DefaultProvider) GetDeviceAuthTokenPollingInterval(ctx context.Context) time.Duration { + return p.p.DurationF(KeyDeviceAuthTokenPollingInterval, time.Second*5) +} + func (p *DefaultProvider) LoginURL(ctx context.Context) *url.URL { return urlRoot(p.getProvider(ctx).URIF(KeyLoginURL, p.publicFallbackURL(ctx, "oauth2/fallbacks/login"))) } @@ -392,6 +407,14 @@ func (p *DefaultProvider) ErrorURL(ctx context.Context) *url.URL { return urlRoot(p.getProvider(ctx).RequestURIF(KeyErrorURL, p.publicFallbackURL(ctx, "oauth2/fallbacks/error"))) } +func (p *DefaultProvider) DeviceUrl(ctx context.Context) *url.URL { + return urlRoot(p.getProvider(ctx).URIF(KeyDeviceURL, p.publicFallbackURL(ctx, "oauth2/fallbacks/device"))) +} + +func (p *DefaultProvider) DeviceDoneURL(ctx context.Context) *url.URL { + return urlRoot(p.getProvider(ctx).RequestURIF(KeyDeviceDoneURL, p.publicFallbackURL(ctx, "oauth2/fallbacks/device/done"))) +} + func (p *DefaultProvider) PublicURL(ctx context.Context) *url.URL { return urlRoot(p.getProvider(ctx).RequestURIF(KeyPublicURL, p.IssuerURL(ctx))) } @@ -449,6 +472,10 @@ func (p *DefaultProvider) OAuth2AuthURL(ctx context.Context) *url.URL { return p.getProvider(ctx).RequestURIF(KeyOAuth2AuthURL, urlx.AppendPaths(p.PublicURL(ctx), "/oauth2/auth")) } +func (p *DefaultProvider) OAuth2DeviceAuthorisationURL(ctx context.Context) *url.URL { + return p.getProvider(ctx).RequestURIF(KeyOAuth2DeviceAuthorisationURL, urlx.AppendPaths(p.PublicURL(ctx), "/oauth2/device/auth")) +} + func (p *DefaultProvider) JWKSURL(ctx context.Context) *url.URL { return p.getProvider(ctx).RequestURIF(KeyJWKSURL, urlx.AppendPaths(p.IssuerURL(ctx), "/.well-known/jwks.json")) } @@ -637,6 +664,10 @@ func (p *DefaultProvider) CookieNameConsentCSRF(ctx context.Context) string { return p.cookieSuffix(ctx, KeyCookieConsentCSRFName) } +func (p *DefaultProvider) CookieNameDeviceVerifyCSRF(ctx context.Context) string { + return p.cookieSuffix(ctx, KeyCookieDeviceVerifyCSRFName) +} + func (p *DefaultProvider) SessionCookieName(ctx context.Context) string { return p.cookieSuffix(ctx, KeyCookieSessionName) } diff --git a/driver/config/provider_test.go b/driver/config/provider_test.go index 8e5c44a9e2e..a46a72683d4 100644 --- a/driver/config/provider_test.go +++ b/driver/config/provider_test.go @@ -279,6 +279,7 @@ func TestViperProviderValidates(t *testing.T) { // webfinger assert.Equal(t, []string{"hydra.openid.id-token", "hydra.jwt.access-token"}, c.WellKnownKeys(ctx)) assert.Equal(t, urlx.ParseOrPanic("https://example.com"), c.OAuth2ClientRegistrationURL(ctx)) + assert.Equal(t, urlx.ParseOrPanic("https://example.com/device_authorization"), c.OAuth2DeviceAuthorisationURL(ctx)) assert.Equal(t, urlx.ParseOrPanic("https://example.com/jwks.json"), c.JWKSURL(ctx)) assert.Equal(t, urlx.ParseOrPanic("https://example.com/auth"), c.OAuth2AuthURL(ctx)) assert.Equal(t, urlx.ParseOrPanic("https://example.com/token"), c.OAuth2TokenURL(ctx)) @@ -297,9 +298,11 @@ func TestViperProviderValidates(t *testing.T) { assert.Equal(t, urlx.ParseOrPanic("https://admin/"), c.AdminURL(ctx)) assert.Equal(t, urlx.ParseOrPanic("https://login/"), c.LoginURL(ctx)) assert.Equal(t, urlx.ParseOrPanic("https://consent/"), c.ConsentURL(ctx)) + assert.Equal(t, urlx.ParseOrPanic("https://device/"), c.DeviceUrl(ctx)) assert.Equal(t, urlx.ParseOrPanic("https://logout/"), c.LogoutURL(ctx)) assert.Equal(t, urlx.ParseOrPanic("https://error/"), c.ErrorURL(ctx)) assert.Equal(t, urlx.ParseOrPanic("https://post_logout/"), c.LogoutRedirectURL(ctx)) + assert.Equal(t, urlx.ParseOrPanic("https://post_device/"), c.DeviceDoneURL(ctx)) // strategies assert.True(t, c.GetScopeStrategy(ctx)([]string{"openid"}, "openid"), "should us fosite.ExactScopeStrategy") @@ -314,12 +317,14 @@ func TestViperProviderValidates(t *testing.T) { assert.Equal(t, 2*time.Hour, c.GetRefreshTokenLifespan(ctx)) assert.Equal(t, 2*time.Hour, c.GetIDTokenLifespan(ctx)) assert.Equal(t, 2*time.Hour, c.GetAuthorizeCodeLifespan(ctx)) + assert.Equal(t, 2*time.Hour, c.GetDeviceAndUserCodeLifespan(ctx)) // oauth2 assert.Equal(t, true, c.GetSendDebugMessagesToClients(ctx)) assert.Equal(t, 20, c.GetBCryptCost(ctx)) assert.Equal(t, true, c.GetEnforcePKCE(ctx)) assert.Equal(t, true, c.GetEnforcePKCEForPublicClients(ctx)) + assert.Equal(t, 2*time.Hour, c.GetDeviceAuthTokenPollingInterval(ctx)) // secrets secret, err := c.GetGlobalSecret(ctx) @@ -388,16 +393,20 @@ func TestLoginConsentURL(t *testing.T) { p := MustNew(context.Background(), l) p.MustSet(ctx, KeyLoginURL, "http://localhost:8080/oauth/login") p.MustSet(ctx, KeyConsentURL, "http://localhost:8080/oauth/consent") + p.MustSet(ctx, KeyDeviceURL, "http://localhost:8080/oauth/device") assert.Equal(t, "http://localhost:8080/oauth/login", p.LoginURL(ctx).String()) assert.Equal(t, "http://localhost:8080/oauth/consent", p.ConsentURL(ctx).String()) + assert.Equal(t, "http://localhost:8080/oauth/device", p.DeviceUrl(ctx).String()) p2 := MustNew(context.Background(), l) p2.MustSet(ctx, KeyLoginURL, "http://localhost:3000/#/oauth/login") p2.MustSet(ctx, KeyConsentURL, "http://localhost:3000/#/oauth/consent") + p2.MustSet(ctx, KeyDeviceURL, "http://localhost:3000/#/oauth/device") assert.Equal(t, "http://localhost:3000/#/oauth/login", p2.LoginURL(ctx).String()) assert.Equal(t, "http://localhost:3000/#/oauth/consent", p2.ConsentURL(ctx).String()) + assert.Equal(t, "http://localhost:3000/#/oauth/device", p2.DeviceUrl(ctx).String()) } func TestInfinitRefreshTokenTTL(t *testing.T) { diff --git a/driver/registry_base.go b/driver/registry_base.go index a541e06ce19..c6100ac669c 100644 --- a/driver/registry_base.go +++ b/driver/registry_base.go @@ -21,6 +21,7 @@ import ( "github.com/ory/fosite/compose" foauth2 "github.com/ory/fosite/handler/oauth2" "github.com/ory/fosite/handler/openid" + "github.com/ory/fosite/handler/rfc8628" "github.com/ory/herodot" "github.com/ory/hydra/v2/aead" "github.com/ory/hydra/v2/client" @@ -87,6 +88,7 @@ type RegistryBase struct { oidcs jwk.JWTSigner ats jwk.JWTSigner hmacs *foauth2.HMACSHAStrategy + devHmac rfc8628.RFC8628CodeStrategy fc *fositex.Config publicCORS *cors.Cors kratos kratos.Client @@ -409,6 +411,15 @@ func (m *RegistryBase) OAuth2HMACStrategy() *foauth2.HMACSHAStrategy { return m.hmacs } +func (m *RegistryBase) RFC8628HMACStrategy() rfc8628.RFC8628CodeStrategy { + if m.devHmac != nil { + return m.devHmac + } + + m.devHmac = compose.NewDeviceStrategy(m.OAuth2Config()) + return m.devHmac +} + func (m *RegistryBase) OAuth2Config() *fositex.Config { if m.fc != nil { return m.fc @@ -435,6 +446,7 @@ func (m *RegistryBase) OAuth2ProviderConfig() fosite.Configurator { conf := m.OAuth2Config() hmacAtStrategy := m.OAuth2HMACStrategy() + devHmacAtStrategy := m.RFC8628HMACStrategy() oidcSigner := m.OpenIDJWTStrategy() atSigner := m.AccessTokenJWTStrategy() jwtAtStrategy := &foauth2.DefaultJWTStrategy{ @@ -449,6 +461,7 @@ func (m *RegistryBase) OAuth2ProviderConfig() fosite.Configurator { HMACSHAStrategy: hmacAtStrategy, Config: conf, }), + RFC8628CodeStrategy: devHmacAtStrategy, OpenIDConnectTokenStrategy: &openid.DefaultStrategy{ Config: conf, Signer: oidcSigner, diff --git a/flow/consent_types.go b/flow/consent_types.go index 9a2666c7867..f8814b92078 100644 --- a/flow/consent_types.go +++ b/flow/consent_types.go @@ -14,9 +14,9 @@ import ( "github.com/gobuffalo/pop/v6" "github.com/gofrs/uuid" + "github.com/ory/fosite" "github.com/ory/x/errorsx" - "github.com/ory/fosite" "github.com/ory/hydra/v2/client" "github.com/ory/x/sqlcon" "github.com/ory/x/sqlxx" @@ -460,6 +460,83 @@ type LogoutResult struct { FrontChannelLogoutURLs []string } +// Contains information on an ongoing device grant request. +// +// swagger:model deviceGrantRequest +type DeviceGrantRequest struct { + // ID is the identifier ("device challenge") of the device grant request. It is used to + // identify the session. + // + // required: true + ID string `json:"challenge" db:"challenge"` + NID uuid.UUID `json:"-" db:"nid"` + + // RequestedScope contains the OAuth 2.0 Scope requested by the OAuth 2.0 Client. + // + // required: true + RequestedScope sqlxx.StringSlicePipeDelimiter `json:"requested_scope" db:"requested_scope"` + + // RequestedScope contains the access token audience as requested by the OAuth 2.0 Client. + // + // required: true + RequestedAudience sqlxx.StringSlicePipeDelimiter `json:"requested_access_token_audience" db:"requested_audience"` + + // RequestURL is the original Device Grant URL requested. + RequestURL string `json:"request_url" db:"request_url"` + + // Client is the OAuth 2.0 Client that initiated the request. + // + // required: true + Client *client.Client `json:"client" db:"-"` + ClientID sqlxx.NullString `json:"-" db:"client_id"` + + // DeviceCodeSignature is the OAuth 2.0 Device Authorization Grant Device Code Signature + // + // required: true + DeviceCodeSignature sqlxx.NullString `json:"-" db:"device_code_signature"` + + CSRF string `json:"-" db:"csrf"` + Verifier string `json:"-" db:"verifier"` + + Accepted bool `json:"-" db:"accepted"` + AcceptedAt sqlxx.NullTime `json:"handled_at" db:"accepted_at"` +} + +func (_ DeviceGrantRequest) TableName() string { + return "hydra_oauth2_device_grant_request" +} + +func (r *DeviceGrantRequest) BeforeSave(_ *pop.Connection) error { + if r.Client != nil { + r.ClientID = sqlxx.NullString(r.Client.GetID()) + } + return nil +} + +func (r *DeviceGrantRequest) AfterFind(c *pop.Connection) error { + if r.ClientID != "" { + r.Client = &client.Client{} + return sqlcon.HandleError(c.Where("id = ?", r.ClientID).First(r.Client)) + } + + return nil +} + +// Contains information on an device verification +// +// swagger:model verifyUserCodeRequest +type DeviceGrantVerifyUserCodeRequest struct { + UserCode string `json:"user_code"` +} + +// Returned when the device grant request was used. +// +// swagger:ignore +type DeviceGrantResponse struct { + RedirectTo string `json:"redirect_to"` + ErrorMessage string `json:"error_message"` +} + // Contains information on an ongoing login request. // // swagger:model oAuth2LoginRequest diff --git a/fositex/config.go b/fositex/config.go index 4377efb1f6d..ec9513d6097 100644 --- a/fositex/config.go +++ b/fositex/config.go @@ -42,13 +42,18 @@ type Config struct { tokenEndpointHandlers fosite.TokenEndpointHandlers tokenIntrospectionHandlers fosite.TokenIntrospectionHandlers revocationHandlers fosite.RevocationHandlers + deviceEndpointHandlers fosite.DeviceEndpointHandlers + deviceUserEndpointHandlers fosite.DeviceUserEndpointHandlers *config.DefaultProvider } var defaultResponseModeHandler = fosite.NewDefaultResponseModeHandler() var defaultFactories = []Factory{ - compose.OAuth2AuthorizeExplicitFactory, + compose.RFC8628DeviceFactory, + compose.RFC8628DeviceAuthorizationTokenFactory, + compose.OAuth2AuthorizeExplicitAuthFactory, + compose.OAuth2AuthorizeExplicitTokenFactory, compose.OAuth2AuthorizeImplicitFactory, compose.OAuth2ClientCredentialsGrantFactory, compose.OAuth2RefreshTokenGrantFactory, @@ -56,6 +61,7 @@ var defaultFactories = []Factory{ compose.OpenIDConnectHybridFactory, compose.OpenIDConnectImplicitFactory, compose.OpenIDConnectRefreshFactory, + compose.OpenIDConnectDeviceFactory, compose.OAuth2TokenRevocationFactory, compose.OAuth2TokenIntrospectionFactory, compose.OAuth2PKCEFactory, @@ -75,6 +81,12 @@ func (c *Config) LoadDefaultHandlers(strategy interface{}) { factories := append(defaultFactories, c.deps.ExtraFositeFactories()...) for _, factory := range factories { res := factory(c, c.deps.Persister(), strategy) + if dh, ok := res.(fosite.DeviceEndpointHandler); ok { + c.deviceEndpointHandlers.Append(dh) + } + if duh, ok := res.(fosite.DeviceUserEndpointHandler); ok { + c.deviceUserEndpointHandlers.Append(duh) + } if ah, ok := res.(fosite.AuthorizeEndpointHandler); ok { c.authorizeEndpointHandlers.Append(ah) } @@ -114,6 +126,14 @@ func (c *Config) GetRevocationHandlers(context.Context) fosite.RevocationHandler return c.revocationHandlers } +func (c *Config) GetDeviceEndpointHandlers(ctx context.Context) fosite.DeviceEndpointHandlers { + return c.deviceEndpointHandlers +} + +func (c *Config) GetDeviceUserEndpointHandlers(ctx context.Context) fosite.DeviceUserEndpointHandlers { + return c.deviceUserEndpointHandlers +} + func (c *Config) GetGrantTypeJWTBearerCanSkipClientAuth(context.Context) bool { return false } @@ -206,3 +226,11 @@ func (c *Config) GetTokenURLs(ctx context.Context) []string { urlx.AppendPaths(c.deps.Config().PublicURL(ctx), oauth2.TokenPath).String(), }) } + +func (c *Config) GetDeviceDone(ctx context.Context) string { + return c.deps.Config().DeviceDoneURL(ctx).String() +} + +func (c *Config) GetDeviceVerificationURL(ctx context.Context) string { + return urlx.AppendPaths(c.deps.Config().PublicURL(ctx), oauth2.DeviceAuthPath).String() +} diff --git a/fositex/token_strategy.go b/fositex/token_strategy.go index 2a84822a246..229eadff5f6 100644 --- a/fositex/token_strategy.go +++ b/fositex/token_strategy.go @@ -9,6 +9,7 @@ import ( "github.com/ory/fosite" foauth2 "github.com/ory/fosite/handler/oauth2" + fdevice "github.com/ory/fosite/handler/rfc8628" "github.com/ory/hydra/v2/client" "github.com/ory/hydra/v2/driver/config" ) @@ -17,9 +18,10 @@ var _ foauth2.CoreStrategy = (*TokenStrategy)(nil) // TokenStrategy uses the correct token strategy (jwt, opaque) depending on the configuration. type TokenStrategy struct { - c *config.DefaultProvider - hmac *foauth2.HMACSHAStrategy - jwt *foauth2.DefaultJWTStrategy + c *config.DefaultProvider + hmac *foauth2.HMACSHAStrategy + devHmac *fdevice.DefaultDeviceStrategy + jwt *foauth2.DefaultJWTStrategy } // NewTokenStrategy returns a new TokenStrategy. diff --git a/go.mod b/go.mod index 89e43c36dad..b1670bab045 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,8 @@ replace ( replace github.com/ory/hydra-client-go/v2 => ./internal/httpclient +replace github.com/ory/fosite => github.com/BuzzBumbleBee/fosite v0.0.0-20240104154951-eee739ab777b + require ( github.com/ThalesIgnite/crypto11 v1.2.5 github.com/bradleyjkemp/cupaloy/v2 v2.8.0 @@ -44,7 +46,7 @@ require ( github.com/ory/hydra-client-go/v2 v2.1.1 github.com/ory/jsonschema/v3 v3.0.8 github.com/ory/kratos-client-go v0.13.1 - github.com/ory/x v0.0.607 + github.com/ory/x v0.0.609 github.com/pborman/uuid v1.2.1 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.16.0 @@ -197,6 +199,7 @@ require ( github.com/openzipkin/zipkin-go v0.4.2 // indirect github.com/ory/dockertest/v3 v3.10.0 // indirect github.com/ory/go-convenience v0.1.0 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.0.9 // indirect github.com/pkg/profile v1.7.0 // indirect @@ -225,8 +228,8 @@ require ( github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect go.mongodb.org/mongo-driver v1.12.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // indirect - go.opentelemetry.io/contrib/propagators/b3 v1.20.0 // indirect - go.opentelemetry.io/contrib/propagators/jaeger v1.20.0 // indirect + go.opentelemetry.io/contrib/propagators/b3 v1.21.0 // indirect + go.opentelemetry.io/contrib/propagators/jaeger v1.21.1 // indirect go.opentelemetry.io/contrib/samplers/jaegerremote v0.15.1 // indirect go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 // indirect @@ -237,6 +240,7 @@ require ( golang.org/x/net v0.18.0 // indirect golang.org/x/sys v0.15.0 // indirect golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.4.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 // indirect diff --git a/go.sum b/go.sum index 2bf92299cfa..f660d8de632 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25 github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/BuzzBumbleBee/fosite v0.0.0-20240104154951-eee739ab777b h1:gxBV6pQPmKk7ZfhpexPJVxQ2w0s3yBRnpCArFugnC7o= +github.com/BuzzBumbleBee/fosite v0.0.0-20240104154951-eee739ab777b/go.mod h1:4P1DxDRBuC8c1VQm4WBvF098Ky3hMU0pl2lKfiN9wtw= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= @@ -587,8 +589,6 @@ github.com/ory/analytics-go/v5 v5.0.1 h1:LX8T5B9FN8KZXOtxgN+R3I4THRRVB6+28IKgKBp github.com/ory/analytics-go/v5 v5.0.1/go.mod h1:lWCiCjAaJkKfgR/BN5DCLMol8BjKS1x+4jxBxff/FF0= github.com/ory/dockertest/v3 v3.10.0 h1:4K3z2VMe8Woe++invjaTB7VRyQXQy5UY+loujO4aNE4= github.com/ory/dockertest/v3 v3.10.0/go.mod h1:nr57ZbRWMqfsdGdFNLHz5jjNdDb7VVFnzAeW1n5N1Lg= -github.com/ory/fosite v0.44.1-0.20231218095112-ac9ae4bd99d7 h1:EZEUk9sdC9cIKSqXipBz4eO84byOLLeVUnptgX7QFvM= -github.com/ory/fosite v0.44.1-0.20231218095112-ac9ae4bd99d7/go.mod h1:fkMPsnm/UjiefE9dE9CdZQGOH48TWJLIzUcdGIXg8Kk= github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe h1:rvu4obdvqR0fkSIJ8IfgzKOWwZ5kOT2UNfLq81Qk7rc= github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe/go.mod h1:z4n3u6as84LbV4YmgjHhnwtccQqzf4cZlSk9f1FhygI= github.com/ory/go-convenience v0.1.0 h1:zouLKfF2GoSGnJwGq+PE/nJAE6dj2Zj5QlTgmMTsTS8= @@ -601,8 +601,10 @@ github.com/ory/jsonschema/v3 v3.0.8 h1:Ssdb3eJ4lDZ/+XnGkvQS/te0p+EkolqwTsDOCxr/F github.com/ory/jsonschema/v3 v3.0.8/go.mod h1:ZPzqjDkwd3QTnb2Z6PAS+OTvBE2x5i6m25wCGx54W/0= github.com/ory/kratos-client-go v0.13.1 h1:o+pFV9ZRMFSBa4QeNJYbJeLz036UWU4p+7yfKghK+0E= github.com/ory/kratos-client-go v0.13.1/go.mod h1:hkrFJuHSBQw+qN6Ks0faOAYhAKwtpjvhCZzsQ7g/Ufc= -github.com/ory/x v0.0.607 h1:qNP1gU6RWVtsEB04rPht+1rV2DqQhvOAN2sF+4eqVWo= -github.com/ory/x v0.0.607/go.mod h1:fCYvVVHo8wYrCwLyU8+9hFY3IRo4EZM3KI30ysDsDYY= +github.com/ory/x v0.0.609 h1:M92c+SyYtjAbyGF4kXvAkPDPq+4NugbHAvx7tGmm+dY= +github.com/ory/x v0.0.609/go.mod h1:Wtu0ZYwP1NEhChLJpSy3NEHnUfOgwNMFiena+hHhmuM= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= @@ -767,10 +769,10 @@ go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0. go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1/go.mod h1:GnOaBaFQ2we3b9AGWJpsBa7v1S5RlQzlC3O7dRMxZhM= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= -go.opentelemetry.io/contrib/propagators/b3 v1.20.0 h1:Yty9Vs4F3D6/liF1o6FNt0PvN85h/BJJ6DQKJ3nrcM0= -go.opentelemetry.io/contrib/propagators/b3 v1.20.0/go.mod h1:On4VgbkqYL18kbJlWsa18+cMNe6rYpBnPi1ARI/BrsU= -go.opentelemetry.io/contrib/propagators/jaeger v1.20.0 h1:iVhNKkMIpzyZqxk8jkDU2n4DFTD+FbpGacvooxEvyyc= -go.opentelemetry.io/contrib/propagators/jaeger v1.20.0/go.mod h1:cpSABr0cm/AH/HhbJjn+AudBVUMgZWdfN3Gb+ZqxSZc= +go.opentelemetry.io/contrib/propagators/b3 v1.21.0 h1:uGdgDPNzwQWRwCXJgw/7h29JaRqcq9B87Iv4hJDKAZw= +go.opentelemetry.io/contrib/propagators/b3 v1.21.0/go.mod h1:D9GQXvVGT2pzyTfp1QBOnD1rzKEWzKjjwu5q2mslCUI= +go.opentelemetry.io/contrib/propagators/jaeger v1.21.1 h1:f4beMGDKiVzg9IcX7/VuWVy+oGdjx3dNJ72YehmtY5k= +go.opentelemetry.io/contrib/propagators/jaeger v1.21.1/go.mod h1:U9jhkEl8d1LL+QXY7q3kneJWJugiN3kZJV2OWz3hkBY= go.opentelemetry.io/contrib/samplers/jaegerremote v0.15.1 h1:Qb+5A+JbIjXwO7l4HkRUhgIn4Bzz0GNS2q+qdmSx+0c= go.opentelemetry.io/contrib/samplers/jaegerremote v0.15.1/go.mod h1:G4vNCm7fRk0kjZ6pGNLo5SpLxAUvOfSrcaegnT8TPck= go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc= diff --git a/internal/.hydra.yaml b/internal/.hydra.yaml index bb02d986ad6..244a18a3c55 100644 --- a/internal/.hydra.yaml +++ b/internal/.hydra.yaml @@ -74,6 +74,7 @@ webfinger: auth_url: https://example.com/auth token_url: https://example.com/token client_registration_url: https://example.com + device_authorization_url: https://example.com/device_authorization supported_claims: - username supported_scope: @@ -99,7 +100,9 @@ urls: login: https://login consent: https://consent logout: https://logout + device: https://device error: https://error + post_device_done: https://post_device post_logout_redirect: https://post_logout strategies: @@ -112,9 +115,12 @@ ttl: refresh_token: 2h id_token: 2h auth_code: 2h + device_user_code: 2h oauth2: expose_internal_errors: true + device_authorization: + token_polling_interval: 2h hashers: bcrypt: cost: 20 diff --git a/internal/config/config.yaml b/internal/config/config.yaml index f3e8bff399c..f57d0e16e78 100644 --- a/internal/config/config.yaml +++ b/internal/config/config.yaml @@ -348,6 +348,10 @@ urls: # to the issuer value. If left unspecified, it falls back to the issuer value. public: https://localhost:4444/ + # This is the device endpoint location of your Ory Hydra installation. + # If left unspecified, it falls back to /device value. + device: https://localhost:4444/device + # Sets the login endpoint of the User Login & Consent flow. Defaults to an internal fallback URL. login: https://my-login.app/login # Sets the consent endpoint of the User Login & Consent flow. Defaults to an internal fallback URL. @@ -359,6 +363,8 @@ urls: error: https://my-error.app/error # When a user agent requests to logout, it will be redirected to this url afterwards per default. post_logout_redirect: https://my-example.app/logout-successful + # When a user agent requests to device auth flow, it will be redirected to this url after a sucessfull login per default. + post_device_done: https://my-example.app/device-successful strategies: scope: DEPRECATED_HIERARCHICAL_SCOPE_STRATEGY @@ -381,6 +387,8 @@ ttl: id_token: 1h # configures how long auth codes are valid. Defaults to 10m. auth_code: 10m + # configures how long device and user codes are valid. Defaults to 10m. + device_user_code: 10m oauth2: # Set this to true if you want to share error debugging information with your OAuth 2.0 clients. @@ -402,6 +410,9 @@ oauth2: session: # store encrypted data in database, default true encrypt_at_rest: true + device_authorization: + # configure how often a non-interactive device should poll the device token endpoint, default 5s + token_polling_interval: 5s # The secrets section configures secrets used for encryption and signing of several systems. All secrets can be rotated, # for more information on this topic navigate to: diff --git a/internal/httpclient/.openapi-generator/FILES b/internal/httpclient/.openapi-generator/FILES index 8fd9b406238..6136e459166 100644 --- a/internal/httpclient/.openapi-generator/FILES +++ b/internal/httpclient/.openapi-generator/FILES @@ -7,6 +7,7 @@ api_jwk.go api_metadata.go api_o_auth2.go api_oidc.go +api_v0alpha2.go api_wellknown.go client.go configuration.go @@ -16,6 +17,8 @@ docs/AcceptOAuth2LoginRequest.md docs/CreateJsonWebKeySet.md docs/CreateVerifiableCredentialRequestBody.md docs/CredentialSupportedDraft00.md +docs/DeviceAuthorization.md +docs/DeviceGrantRequest.md docs/ErrorOAuth2.md docs/GenericError.md docs/GetVersion200Response.md @@ -54,9 +57,11 @@ docs/TokenPaginationResponseHeaders.md docs/TrustOAuth2JwtGrantIssuer.md docs/TrustedOAuth2JwtGrantIssuer.md docs/TrustedOAuth2JwtGrantJsonWebKey.md +docs/V0alpha2Api.md docs/VerifiableCredentialPrimingResponse.md docs/VerifiableCredentialProof.md docs/VerifiableCredentialResponse.md +docs/VerifyUserCodeRequest.md docs/Version.md docs/WellknownApi.md git_push.sh @@ -68,6 +73,8 @@ model_accept_o_auth2_login_request.go model_create_json_web_key_set.go model_create_verifiable_credential_request_body.go model_credential_supported_draft00.go +model_device_authorization.go +model_device_grant_request.go model_error_o_auth2.go model_generic_error.go model_get_version_200_response.go @@ -105,6 +112,7 @@ model_trusted_o_auth2_jwt_grant_json_web_key.go model_verifiable_credential_priming_response.go model_verifiable_credential_proof.go model_verifiable_credential_response.go +model_verify_user_code_request.go model_version.go response.go utils.go diff --git a/internal/httpclient/README.md b/internal/httpclient/README.md index 54e38678e69..a521573ea73 100644 --- a/internal/httpclient/README.md +++ b/internal/httpclient/README.md @@ -117,6 +117,7 @@ Class | Method | HTTP request | Description *OAuth2Api* | [**SetOAuth2Client**](docs/OAuth2Api.md#setoauth2client) | **Put** /admin/clients/{id} | Set OAuth 2.0 Client *OAuth2Api* | [**SetOAuth2ClientLifespans**](docs/OAuth2Api.md#setoauth2clientlifespans) | **Put** /admin/clients/{id}/lifespans | Set OAuth2 Client Token Lifespans *OAuth2Api* | [**TrustOAuth2JwtGrantIssuer**](docs/OAuth2Api.md#trustoauth2jwtgrantissuer) | **Post** /admin/trust/grants/jwt-bearer/issuers | Trust OAuth2 JWT Bearer Grant Type Issuer +*OAuth2Api* | [**VerifyUserCodeRequest**](docs/OAuth2Api.md#verifyusercoderequest) | **Put** /admin/oauth2/auth/requests/device/verify | Verifies a device grant request *OidcApi* | [**CreateOidcDynamicClient**](docs/OidcApi.md#createoidcdynamicclient) | **Post** /oauth2/register | Register OAuth2 Client using OpenID Dynamic Client Registration *OidcApi* | [**CreateVerifiableCredential**](docs/OidcApi.md#createverifiablecredential) | **Post** /credentials | Issues a Verifiable Credential *OidcApi* | [**DeleteOidcDynamicClient**](docs/OidcApi.md#deleteoidcdynamicclient) | **Delete** /oauth2/register/{id} | Delete OAuth 2.0 Client using the OpenID Dynamic Client Registration Management Protocol @@ -125,6 +126,7 @@ Class | Method | HTTP request | Description *OidcApi* | [**GetOidcUserInfo**](docs/OidcApi.md#getoidcuserinfo) | **Get** /userinfo | OpenID Connect Userinfo *OidcApi* | [**RevokeOidcSession**](docs/OidcApi.md#revokeoidcsession) | **Get** /oauth2/sessions/logout | OpenID Connect Front- and Back-channel Enabled Logout *OidcApi* | [**SetOidcDynamicClient**](docs/OidcApi.md#setoidcdynamicclient) | **Put** /oauth2/register/{id} | Set OAuth2 Client using OpenID Dynamic Client Registration +*V0alpha2Api* | [**PerformOAuth2DeviceFlow**](docs/V0alpha2Api.md#performoauth2deviceflow) | **Get** /oauth2/device/auth | The OAuth 2.0 Device Authorize Endpoint *WellknownApi* | [**DiscoverJsonWebKeys**](docs/WellknownApi.md#discoverjsonwebkeys) | **Get** /.well-known/jwks.json | Discover Well-Known JSON Web Keys @@ -136,6 +138,8 @@ Class | Method | HTTP request | Description - [CreateJsonWebKeySet](docs/CreateJsonWebKeySet.md) - [CreateVerifiableCredentialRequestBody](docs/CreateVerifiableCredentialRequestBody.md) - [CredentialSupportedDraft00](docs/CredentialSupportedDraft00.md) + - [DeviceAuthorization](docs/DeviceAuthorization.md) + - [DeviceGrantRequest](docs/DeviceGrantRequest.md) - [ErrorOAuth2](docs/ErrorOAuth2.md) - [GenericError](docs/GenericError.md) - [GetVersion200Response](docs/GetVersion200Response.md) @@ -173,6 +177,7 @@ Class | Method | HTTP request | Description - [VerifiableCredentialPrimingResponse](docs/VerifiableCredentialPrimingResponse.md) - [VerifiableCredentialProof](docs/VerifiableCredentialProof.md) - [VerifiableCredentialResponse](docs/VerifiableCredentialResponse.md) + - [VerifyUserCodeRequest](docs/VerifyUserCodeRequest.md) - [Version](docs/Version.md) diff --git a/internal/httpclient/api/openapi.yaml b/internal/httpclient/api/openapi.yaml index 4d8823c5ac1..61b9bce604b 100644 --- a/internal/httpclient/api/openapi.yaml +++ b/internal/httpclient/api/openapi.yaml @@ -786,6 +786,40 @@ paths: summary: Reject OAuth 2.0 Consent Request tags: - oAuth2 + /admin/oauth2/auth/requests/device/verify: + put: + description: Verifies a device grant request + operationId: verifyUserCodeRequest + parameters: + - explode: true + in: query + name: device_challenge + required: true + schema: + type: string + style: form + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/verifyUserCodeRequest' + x-originalParamName: Body + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/oAuth2RedirectTo' + description: oAuth2RedirectTo + default: + content: + application/json: + schema: + $ref: '#/components/schemas/errorOAuth2' + description: errorOAuth2 + summary: Verifies a device grant request + tags: + - oAuth2 /admin/oauth2/auth/requests/login: get: description: "When an authorization code, hybrid, or implicit OAuth 2.0 Flow\ @@ -1475,6 +1509,29 @@ paths: summary: OAuth 2.0 Authorize Endpoint tags: - oAuth2 + /oauth2/device/auth: + get: + description: "This endpoint is not documented here because you should never\ + \ use your own implementation to perform OAuth2 flows.\nOAuth2 is a very popular\ + \ protocol and a library for your programming language will exists.\n\nTo\ + \ learn more about this flow please refer to the specification: https://tools.ietf.org/html/rfc8628" + operationId: performOAuth2DeviceFlow + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/deviceAuthorization' + description: deviceAuthorization + default: + content: + application/json: + schema: + $ref: '#/components/schemas/errorOAuth2' + description: errorOAuth2 + summary: The OAuth 2.0 Device Authorize Endpoint + tags: + - v0alpha2 /oauth2/register: post: description: |- @@ -1877,6 +1934,11 @@ components: title: "StringSliceJSONFormat represents []string{} which is encoded to/from\ \ JSON for SQL storage." type: array + StringSlicePipeDelimiter: + items: + type: string + title: StringSlicePipeDelimiter de/encodes the string slice to/from a SQL string. + type: array Time: format: date-time type: string @@ -2086,6 +2148,86 @@ components: type: array title: Verifiable Credentials Metadata (Draft 00) type: object + deviceAuthorization: + description: '# Ory''s OAuth 2.0 Device Authorization API' + example: + user_code: AAAAAA + device_code: ory_dc_smldfksmdfkl.mslkmlkmlk + interval: 5 + verification_uri_complete: https://auth.ory.sh/tv?user_code=AAAAAA + verification_uri: https://auth.ory.sh/tv + expires_in: 16830 + properties: + device_code: + description: The device verification code. + example: ory_dc_smldfksmdfkl.mslkmlkmlk + type: string + expires_in: + description: The lifetime in seconds of the "device_code" and "user_code". + example: 16830 + format: int64 + type: integer + interval: + description: "The minimum amount of time in seconds that the client\nSHOULD\ + \ wait between polling requests to the token endpoint. If no\nvalue is\ + \ provided, clients MUST use 5 as the default." + example: 5 + format: int64 + type: integer + user_code: + description: The end-user verification code. + example: AAAAAA + type: string + verification_uri: + description: |- + The end-user verification URI on the authorization + server. The URI should be short and easy to remember as end users + will be asked to manually type it into their user agent. + example: https://auth.ory.sh/tv + type: string + verification_uri_complete: + description: "A verification URI that includes the \"user_code\" (or\nother\ + \ information with the same function as the \"user_code\"),\nwhich is\ + \ designed for non-textual transmission." + example: https://auth.ory.sh/tv?user_code=AAAAAA + type: string + title: OAuth2 Device Flow + type: object + deviceGrantRequest: + properties: + challenge: + description: |- + ID is the identifier ("device challenge") of the device grant request. It is used to + identify the session. + type: string + client: + $ref: '#/components/schemas/oAuth2Client' + handled_at: + format: date-time + title: NullTime implements sql.NullTime functionality. + type: string + request_url: + description: RequestURL is the original Device Grant URL requested. + type: string + requested_access_token_audience: + items: + type: string + title: StringSlicePipeDelimiter de/encodes the string slice to/from a SQL + string. + type: array + requested_scope: + items: + type: string + title: StringSlicePipeDelimiter de/encodes the string slice to/from a SQL + string. + type: array + required: + - challenge + - client + - requested_access_token_audience + - requested_scope + title: Contains information on an ongoing device grant request. + type: object errorOAuth2: description: Error example: @@ -3628,6 +3770,7 @@ components: - userinfo_signed_response_alg - userinfo_signed_response_alg authorization_endpoint: https://playground.ory.sh/ory-hydra/public/oauth2/auth + device_authorization_endpoint: device_authorization_endpoint claims_supported: - claims_supported - claims_supported @@ -3744,6 +3887,9 @@ components: items: $ref: '#/components/schemas/credentialSupportedDraft00' type: array + device_authorization_endpoint: + description: URL of the authorization server's device authorization endpoint + type: string end_session_endpoint: description: |- OpenID Connect End-Session Endpoint @@ -4350,6 +4496,12 @@ components: type: string title: VerifiableCredentialResponse contains the verifiable credential. type: object + verifyUserCodeRequest: + description: Contains information on an device verification + properties: + user_code: + type: string + type: object version: properties: version: diff --git a/internal/httpclient/api_o_auth2.go b/internal/httpclient/api_o_auth2.go index f1b8f0348ae..91786e90a5a 100644 --- a/internal/httpclient/api_o_auth2.go +++ b/internal/httpclient/api_o_auth2.go @@ -3598,3 +3598,128 @@ func (a *OAuth2ApiService) TrustOAuth2JwtGrantIssuerExecute(r ApiTrustOAuth2JwtG return localVarReturnValue, localVarHTTPResponse, nil } + +type ApiVerifyUserCodeRequestRequest struct { + ctx context.Context + ApiService *OAuth2ApiService + deviceChallenge *string + verifyUserCodeRequest *VerifyUserCodeRequest +} + +func (r ApiVerifyUserCodeRequestRequest) DeviceChallenge(deviceChallenge string) ApiVerifyUserCodeRequestRequest { + r.deviceChallenge = &deviceChallenge + return r +} + +func (r ApiVerifyUserCodeRequestRequest) VerifyUserCodeRequest(verifyUserCodeRequest VerifyUserCodeRequest) ApiVerifyUserCodeRequestRequest { + r.verifyUserCodeRequest = &verifyUserCodeRequest + return r +} + +func (r ApiVerifyUserCodeRequestRequest) Execute() (*OAuth2RedirectTo, *http.Response, error) { + return r.ApiService.VerifyUserCodeRequestExecute(r) +} + +/* +VerifyUserCodeRequest Verifies a device grant request + +Verifies a device grant request + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return ApiVerifyUserCodeRequestRequest +*/ +func (a *OAuth2ApiService) VerifyUserCodeRequest(ctx context.Context) ApiVerifyUserCodeRequestRequest { + return ApiVerifyUserCodeRequestRequest{ + ApiService: a, + ctx: ctx, + } +} + +// Execute executes the request +// +// @return OAuth2RedirectTo +func (a *OAuth2ApiService) VerifyUserCodeRequestExecute(r ApiVerifyUserCodeRequestRequest) (*OAuth2RedirectTo, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPut + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *OAuth2RedirectTo + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "OAuth2ApiService.VerifyUserCodeRequest") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/admin/oauth2/auth/requests/device/verify" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + if r.deviceChallenge == nil { + return localVarReturnValue, nil, reportError("deviceChallenge is required and must be specified") + } + + localVarQueryParams.Add("device_challenge", parameterToString(*r.deviceChallenge, "")) + // to determine the Content-Type header + localVarHTTPContentTypes := []string{"application/json"} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + // body params + localVarPostBody = r.verifyUserCodeRequest + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := ioutil.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = ioutil.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + var v ErrorOAuth2 + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} diff --git a/internal/httpclient/api_v0alpha2.go b/internal/httpclient/api_v0alpha2.go new file mode 100644 index 00000000000..37b8174a3a8 --- /dev/null +++ b/internal/httpclient/api_v0alpha2.go @@ -0,0 +1,133 @@ +/* +Ory Hydra API + +Documentation for all of Ory Hydra's APIs. + +API version: +Contact: hi@ory.sh +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package openapi + +import ( + "bytes" + "context" + "io/ioutil" + "net/http" + "net/url" +) + +// V0alpha2ApiService V0alpha2Api service +type V0alpha2ApiService service + +type ApiPerformOAuth2DeviceFlowRequest struct { + ctx context.Context + ApiService *V0alpha2ApiService +} + +func (r ApiPerformOAuth2DeviceFlowRequest) Execute() (*DeviceAuthorization, *http.Response, error) { + return r.ApiService.PerformOAuth2DeviceFlowExecute(r) +} + +/* +PerformOAuth2DeviceFlow The OAuth 2.0 Device Authorize Endpoint + +This endpoint is not documented here because you should never use your own implementation to perform OAuth2 flows. +OAuth2 is a very popular protocol and a library for your programming language will exists. + +To learn more about this flow please refer to the specification: https://tools.ietf.org/html/rfc8628 + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return ApiPerformOAuth2DeviceFlowRequest +*/ +func (a *V0alpha2ApiService) PerformOAuth2DeviceFlow(ctx context.Context) ApiPerformOAuth2DeviceFlowRequest { + return ApiPerformOAuth2DeviceFlowRequest{ + ApiService: a, + ctx: ctx, + } +} + +// Execute executes the request +// +// @return DeviceAuthorization +func (a *V0alpha2ApiService) PerformOAuth2DeviceFlowExecute(r ApiPerformOAuth2DeviceFlowRequest) (*DeviceAuthorization, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *DeviceAuthorization + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "V0alpha2ApiService.PerformOAuth2DeviceFlow") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/oauth2/device/auth" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := ioutil.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = ioutil.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + var v ErrorOAuth2 + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} diff --git a/internal/httpclient/client.go b/internal/httpclient/client.go index fe7ccccad0b..2aea075c664 100644 --- a/internal/httpclient/client.go +++ b/internal/httpclient/client.go @@ -58,6 +58,8 @@ type APIClient struct { OidcApi *OidcApiService + V0alpha2Api *V0alpha2ApiService + WellknownApi *WellknownApiService } @@ -81,6 +83,7 @@ func NewAPIClient(cfg *Configuration) *APIClient { c.MetadataApi = (*MetadataApiService)(&c.common) c.OAuth2Api = (*OAuth2ApiService)(&c.common) c.OidcApi = (*OidcApiService)(&c.common) + c.V0alpha2Api = (*V0alpha2ApiService)(&c.common) c.WellknownApi = (*WellknownApiService)(&c.common) return c diff --git a/internal/httpclient/docs/DeviceAuthorization.md b/internal/httpclient/docs/DeviceAuthorization.md new file mode 100644 index 00000000000..4ba933a4b24 --- /dev/null +++ b/internal/httpclient/docs/DeviceAuthorization.md @@ -0,0 +1,186 @@ +# DeviceAuthorization + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**DeviceCode** | Pointer to **string** | The device verification code. | [optional] +**ExpiresIn** | Pointer to **int64** | The lifetime in seconds of the \"device_code\" and \"user_code\". | [optional] +**Interval** | Pointer to **int64** | The minimum amount of time in seconds that the client SHOULD wait between polling requests to the token endpoint. If no value is provided, clients MUST use 5 as the default. | [optional] +**UserCode** | Pointer to **string** | The end-user verification code. | [optional] +**VerificationUri** | Pointer to **string** | The end-user verification URI on the authorization server. The URI should be short and easy to remember as end users will be asked to manually type it into their user agent. | [optional] +**VerificationUriComplete** | Pointer to **string** | A verification URI that includes the \"user_code\" (or other information with the same function as the \"user_code\"), which is designed for non-textual transmission. | [optional] + +## Methods + +### NewDeviceAuthorization + +`func NewDeviceAuthorization() *DeviceAuthorization` + +NewDeviceAuthorization instantiates a new DeviceAuthorization object +This constructor will assign default values to properties that have it defined, +and makes sure properties required by API are set, but the set of arguments +will change when the set of required properties is changed + +### NewDeviceAuthorizationWithDefaults + +`func NewDeviceAuthorizationWithDefaults() *DeviceAuthorization` + +NewDeviceAuthorizationWithDefaults instantiates a new DeviceAuthorization object +This constructor will only assign default values to properties that have it defined, +but it doesn't guarantee that properties required by API are set + +### GetDeviceCode + +`func (o *DeviceAuthorization) GetDeviceCode() string` + +GetDeviceCode returns the DeviceCode field if non-nil, zero value otherwise. + +### GetDeviceCodeOk + +`func (o *DeviceAuthorization) GetDeviceCodeOk() (*string, bool)` + +GetDeviceCodeOk returns a tuple with the DeviceCode field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetDeviceCode + +`func (o *DeviceAuthorization) SetDeviceCode(v string)` + +SetDeviceCode sets DeviceCode field to given value. + +### HasDeviceCode + +`func (o *DeviceAuthorization) HasDeviceCode() bool` + +HasDeviceCode returns a boolean if a field has been set. + +### GetExpiresIn + +`func (o *DeviceAuthorization) GetExpiresIn() int64` + +GetExpiresIn returns the ExpiresIn field if non-nil, zero value otherwise. + +### GetExpiresInOk + +`func (o *DeviceAuthorization) GetExpiresInOk() (*int64, bool)` + +GetExpiresInOk returns a tuple with the ExpiresIn field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetExpiresIn + +`func (o *DeviceAuthorization) SetExpiresIn(v int64)` + +SetExpiresIn sets ExpiresIn field to given value. + +### HasExpiresIn + +`func (o *DeviceAuthorization) HasExpiresIn() bool` + +HasExpiresIn returns a boolean if a field has been set. + +### GetInterval + +`func (o *DeviceAuthorization) GetInterval() int64` + +GetInterval returns the Interval field if non-nil, zero value otherwise. + +### GetIntervalOk + +`func (o *DeviceAuthorization) GetIntervalOk() (*int64, bool)` + +GetIntervalOk returns a tuple with the Interval field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetInterval + +`func (o *DeviceAuthorization) SetInterval(v int64)` + +SetInterval sets Interval field to given value. + +### HasInterval + +`func (o *DeviceAuthorization) HasInterval() bool` + +HasInterval returns a boolean if a field has been set. + +### GetUserCode + +`func (o *DeviceAuthorization) GetUserCode() string` + +GetUserCode returns the UserCode field if non-nil, zero value otherwise. + +### GetUserCodeOk + +`func (o *DeviceAuthorization) GetUserCodeOk() (*string, bool)` + +GetUserCodeOk returns a tuple with the UserCode field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetUserCode + +`func (o *DeviceAuthorization) SetUserCode(v string)` + +SetUserCode sets UserCode field to given value. + +### HasUserCode + +`func (o *DeviceAuthorization) HasUserCode() bool` + +HasUserCode returns a boolean if a field has been set. + +### GetVerificationUri + +`func (o *DeviceAuthorization) GetVerificationUri() string` + +GetVerificationUri returns the VerificationUri field if non-nil, zero value otherwise. + +### GetVerificationUriOk + +`func (o *DeviceAuthorization) GetVerificationUriOk() (*string, bool)` + +GetVerificationUriOk returns a tuple with the VerificationUri field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetVerificationUri + +`func (o *DeviceAuthorization) SetVerificationUri(v string)` + +SetVerificationUri sets VerificationUri field to given value. + +### HasVerificationUri + +`func (o *DeviceAuthorization) HasVerificationUri() bool` + +HasVerificationUri returns a boolean if a field has been set. + +### GetVerificationUriComplete + +`func (o *DeviceAuthorization) GetVerificationUriComplete() string` + +GetVerificationUriComplete returns the VerificationUriComplete field if non-nil, zero value otherwise. + +### GetVerificationUriCompleteOk + +`func (o *DeviceAuthorization) GetVerificationUriCompleteOk() (*string, bool)` + +GetVerificationUriCompleteOk returns a tuple with the VerificationUriComplete field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetVerificationUriComplete + +`func (o *DeviceAuthorization) SetVerificationUriComplete(v string)` + +SetVerificationUriComplete sets VerificationUriComplete field to given value. + +### HasVerificationUriComplete + +`func (o *DeviceAuthorization) HasVerificationUriComplete() bool` + +HasVerificationUriComplete returns a boolean if a field has been set. + + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/internal/httpclient/docs/DeviceGrantRequest.md b/internal/httpclient/docs/DeviceGrantRequest.md new file mode 100644 index 00000000000..57723ef98a1 --- /dev/null +++ b/internal/httpclient/docs/DeviceGrantRequest.md @@ -0,0 +1,166 @@ +# DeviceGrantRequest + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Challenge** | **string** | ID is the identifier (\"device challenge\") of the device grant request. It is used to identify the session. | +**Client** | [**OAuth2Client**](OAuth2Client.md) | | +**HandledAt** | Pointer to **time.Time** | | [optional] +**RequestUrl** | Pointer to **string** | RequestURL is the original Device Grant URL requested. | [optional] +**RequestedAccessTokenAudience** | **[]string** | | +**RequestedScope** | **[]string** | | + +## Methods + +### NewDeviceGrantRequest + +`func NewDeviceGrantRequest(challenge string, client OAuth2Client, requestedAccessTokenAudience []string, requestedScope []string, ) *DeviceGrantRequest` + +NewDeviceGrantRequest instantiates a new DeviceGrantRequest object +This constructor will assign default values to properties that have it defined, +and makes sure properties required by API are set, but the set of arguments +will change when the set of required properties is changed + +### NewDeviceGrantRequestWithDefaults + +`func NewDeviceGrantRequestWithDefaults() *DeviceGrantRequest` + +NewDeviceGrantRequestWithDefaults instantiates a new DeviceGrantRequest object +This constructor will only assign default values to properties that have it defined, +but it doesn't guarantee that properties required by API are set + +### GetChallenge + +`func (o *DeviceGrantRequest) GetChallenge() string` + +GetChallenge returns the Challenge field if non-nil, zero value otherwise. + +### GetChallengeOk + +`func (o *DeviceGrantRequest) GetChallengeOk() (*string, bool)` + +GetChallengeOk returns a tuple with the Challenge field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetChallenge + +`func (o *DeviceGrantRequest) SetChallenge(v string)` + +SetChallenge sets Challenge field to given value. + + +### GetClient + +`func (o *DeviceGrantRequest) GetClient() OAuth2Client` + +GetClient returns the Client field if non-nil, zero value otherwise. + +### GetClientOk + +`func (o *DeviceGrantRequest) GetClientOk() (*OAuth2Client, bool)` + +GetClientOk returns a tuple with the Client field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetClient + +`func (o *DeviceGrantRequest) SetClient(v OAuth2Client)` + +SetClient sets Client field to given value. + + +### GetHandledAt + +`func (o *DeviceGrantRequest) GetHandledAt() time.Time` + +GetHandledAt returns the HandledAt field if non-nil, zero value otherwise. + +### GetHandledAtOk + +`func (o *DeviceGrantRequest) GetHandledAtOk() (*time.Time, bool)` + +GetHandledAtOk returns a tuple with the HandledAt field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetHandledAt + +`func (o *DeviceGrantRequest) SetHandledAt(v time.Time)` + +SetHandledAt sets HandledAt field to given value. + +### HasHandledAt + +`func (o *DeviceGrantRequest) HasHandledAt() bool` + +HasHandledAt returns a boolean if a field has been set. + +### GetRequestUrl + +`func (o *DeviceGrantRequest) GetRequestUrl() string` + +GetRequestUrl returns the RequestUrl field if non-nil, zero value otherwise. + +### GetRequestUrlOk + +`func (o *DeviceGrantRequest) GetRequestUrlOk() (*string, bool)` + +GetRequestUrlOk returns a tuple with the RequestUrl field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetRequestUrl + +`func (o *DeviceGrantRequest) SetRequestUrl(v string)` + +SetRequestUrl sets RequestUrl field to given value. + +### HasRequestUrl + +`func (o *DeviceGrantRequest) HasRequestUrl() bool` + +HasRequestUrl returns a boolean if a field has been set. + +### GetRequestedAccessTokenAudience + +`func (o *DeviceGrantRequest) GetRequestedAccessTokenAudience() []string` + +GetRequestedAccessTokenAudience returns the RequestedAccessTokenAudience field if non-nil, zero value otherwise. + +### GetRequestedAccessTokenAudienceOk + +`func (o *DeviceGrantRequest) GetRequestedAccessTokenAudienceOk() (*[]string, bool)` + +GetRequestedAccessTokenAudienceOk returns a tuple with the RequestedAccessTokenAudience field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetRequestedAccessTokenAudience + +`func (o *DeviceGrantRequest) SetRequestedAccessTokenAudience(v []string)` + +SetRequestedAccessTokenAudience sets RequestedAccessTokenAudience field to given value. + + +### GetRequestedScope + +`func (o *DeviceGrantRequest) GetRequestedScope() []string` + +GetRequestedScope returns the RequestedScope field if non-nil, zero value otherwise. + +### GetRequestedScopeOk + +`func (o *DeviceGrantRequest) GetRequestedScopeOk() (*[]string, bool)` + +GetRequestedScopeOk returns a tuple with the RequestedScope field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetRequestedScope + +`func (o *DeviceGrantRequest) SetRequestedScope(v []string)` + +SetRequestedScope sets RequestedScope field to given value. + + + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/internal/httpclient/docs/OAuth2Api.md b/internal/httpclient/docs/OAuth2Api.md index c5b4aff638c..51d43ec198f 100644 --- a/internal/httpclient/docs/OAuth2Api.md +++ b/internal/httpclient/docs/OAuth2Api.md @@ -32,6 +32,7 @@ Method | HTTP request | Description [**SetOAuth2Client**](OAuth2Api.md#SetOAuth2Client) | **Put** /admin/clients/{id} | Set OAuth 2.0 Client [**SetOAuth2ClientLifespans**](OAuth2Api.md#SetOAuth2ClientLifespans) | **Put** /admin/clients/{id}/lifespans | Set OAuth2 Client Token Lifespans [**TrustOAuth2JwtGrantIssuer**](OAuth2Api.md#TrustOAuth2JwtGrantIssuer) | **Post** /admin/trust/grants/jwt-bearer/issuers | Trust OAuth2 JWT Bearer Grant Type Issuer +[**VerifyUserCodeRequest**](OAuth2Api.md#VerifyUserCodeRequest) | **Put** /admin/oauth2/auth/requests/device/verify | Verifies a device grant request @@ -1942,3 +1943,71 @@ No authorization required [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +## VerifyUserCodeRequest + +> OAuth2RedirectTo VerifyUserCodeRequest(ctx).DeviceChallenge(deviceChallenge).VerifyUserCodeRequest(verifyUserCodeRequest).Execute() + +Verifies a device grant request + + + +### Example + +```go +package main + +import ( + "context" + "fmt" + "os" + openapiclient "./openapi" +) + +func main() { + deviceChallenge := "deviceChallenge_example" // string | + verifyUserCodeRequest := *openapiclient.NewVerifyUserCodeRequest() // VerifyUserCodeRequest | (optional) + + configuration := openapiclient.NewConfiguration() + apiClient := openapiclient.NewAPIClient(configuration) + resp, r, err := apiClient.OAuth2Api.VerifyUserCodeRequest(context.Background()).DeviceChallenge(deviceChallenge).VerifyUserCodeRequest(verifyUserCodeRequest).Execute() + if err != nil { + fmt.Fprintf(os.Stderr, "Error when calling `OAuth2Api.VerifyUserCodeRequest``: %v\n", err) + fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r) + } + // response from `VerifyUserCodeRequest`: OAuth2RedirectTo + fmt.Fprintf(os.Stdout, "Response from `OAuth2Api.VerifyUserCodeRequest`: %v\n", resp) +} +``` + +### Path Parameters + + + +### Other Parameters + +Other parameters are passed through a pointer to a apiVerifyUserCodeRequestRequest struct via the builder pattern + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **deviceChallenge** | **string** | | + **verifyUserCodeRequest** | [**VerifyUserCodeRequest**](VerifyUserCodeRequest.md) | | + +### Return type + +[**OAuth2RedirectTo**](OAuth2RedirectTo.md) + +### Authorization + +No authorization required + +### HTTP request headers + +- **Content-Type**: application/json +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) +[[Back to Model list]](../README.md#documentation-for-models) +[[Back to README]](../README.md) + diff --git a/internal/httpclient/docs/OidcConfiguration.md b/internal/httpclient/docs/OidcConfiguration.md index 1b20c7d8733..aa620757a0d 100644 --- a/internal/httpclient/docs/OidcConfiguration.md +++ b/internal/httpclient/docs/OidcConfiguration.md @@ -12,6 +12,7 @@ Name | Type | Description | Notes **CodeChallengeMethodsSupported** | Pointer to **[]string** | OAuth 2.0 PKCE Supported Code Challenge Methods JSON array containing a list of Proof Key for Code Exchange (PKCE) [RFC7636] code challenge methods supported by this authorization server. | [optional] **CredentialsEndpointDraft00** | Pointer to **string** | OpenID Connect Verifiable Credentials Endpoint Contains the URL of the Verifiable Credentials Endpoint. | [optional] **CredentialsSupportedDraft00** | Pointer to [**[]CredentialSupportedDraft00**](CredentialSupportedDraft00.md) | OpenID Connect Verifiable Credentials Supported JSON array containing a list of the Verifiable Credentials supported by this authorization server. | [optional] +**DeviceAuthorizationEndpoint** | Pointer to **string** | URL of the authorization server's device authorization endpoint | [optional] **EndSessionEndpoint** | Pointer to **string** | OpenID Connect End-Session Endpoint URL at the OP to which an RP can perform a redirect to request that the End-User be logged out at the OP. | [optional] **FrontchannelLogoutSessionSupported** | Pointer to **bool** | OpenID Connect Front-Channel Logout Session Required Boolean value specifying whether the OP can pass iss (issuer) and sid (session ID) query parameters to identify the RP session with the OP when the frontchannel_logout_uri is used. If supported, the sid Claim is also included in ID Tokens issued by the OP. | [optional] **FrontchannelLogoutSupported** | Pointer to **bool** | OpenID Connect Front-Channel Logout Supported Boolean value specifying whether the OP supports HTTP-based logout, with true indicating support. | [optional] @@ -250,6 +251,31 @@ SetCredentialsSupportedDraft00 sets CredentialsSupportedDraft00 field to given v HasCredentialsSupportedDraft00 returns a boolean if a field has been set. +### GetDeviceAuthorizationEndpoint + +`func (o *OidcConfiguration) GetDeviceAuthorizationEndpoint() string` + +GetDeviceAuthorizationEndpoint returns the DeviceAuthorizationEndpoint field if non-nil, zero value otherwise. + +### GetDeviceAuthorizationEndpointOk + +`func (o *OidcConfiguration) GetDeviceAuthorizationEndpointOk() (*string, bool)` + +GetDeviceAuthorizationEndpointOk returns a tuple with the DeviceAuthorizationEndpoint field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetDeviceAuthorizationEndpoint + +`func (o *OidcConfiguration) SetDeviceAuthorizationEndpoint(v string)` + +SetDeviceAuthorizationEndpoint sets DeviceAuthorizationEndpoint field to given value. + +### HasDeviceAuthorizationEndpoint + +`func (o *OidcConfiguration) HasDeviceAuthorizationEndpoint() bool` + +HasDeviceAuthorizationEndpoint returns a boolean if a field has been set. + ### GetEndSessionEndpoint `func (o *OidcConfiguration) GetEndSessionEndpoint() string` diff --git a/internal/httpclient/docs/V0alpha2Api.md b/internal/httpclient/docs/V0alpha2Api.md new file mode 100644 index 00000000000..593b0adc85f --- /dev/null +++ b/internal/httpclient/docs/V0alpha2Api.md @@ -0,0 +1,70 @@ +# \V0alpha2Api + +All URIs are relative to *http://localhost* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**PerformOAuth2DeviceFlow**](V0alpha2Api.md#PerformOAuth2DeviceFlow) | **Get** /oauth2/device/auth | The OAuth 2.0 Device Authorize Endpoint + + + +## PerformOAuth2DeviceFlow + +> DeviceAuthorization PerformOAuth2DeviceFlow(ctx).Execute() + +The OAuth 2.0 Device Authorize Endpoint + + + +### Example + +```go +package main + +import ( + "context" + "fmt" + "os" + openapiclient "./openapi" +) + +func main() { + + configuration := openapiclient.NewConfiguration() + apiClient := openapiclient.NewAPIClient(configuration) + resp, r, err := apiClient.V0alpha2Api.PerformOAuth2DeviceFlow(context.Background()).Execute() + if err != nil { + fmt.Fprintf(os.Stderr, "Error when calling `V0alpha2Api.PerformOAuth2DeviceFlow``: %v\n", err) + fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r) + } + // response from `PerformOAuth2DeviceFlow`: DeviceAuthorization + fmt.Fprintf(os.Stdout, "Response from `V0alpha2Api.PerformOAuth2DeviceFlow`: %v\n", resp) +} +``` + +### Path Parameters + +This endpoint does not need any parameter. + +### Other Parameters + +Other parameters are passed through a pointer to a apiPerformOAuth2DeviceFlowRequest struct via the builder pattern + + +### Return type + +[**DeviceAuthorization**](DeviceAuthorization.md) + +### Authorization + +No authorization required + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) +[[Back to Model list]](../README.md#documentation-for-models) +[[Back to README]](../README.md) + diff --git a/internal/httpclient/docs/VerifyUserCodeRequest.md b/internal/httpclient/docs/VerifyUserCodeRequest.md new file mode 100644 index 00000000000..021874d1944 --- /dev/null +++ b/internal/httpclient/docs/VerifyUserCodeRequest.md @@ -0,0 +1,56 @@ +# VerifyUserCodeRequest + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**UserCode** | Pointer to **string** | | [optional] + +## Methods + +### NewVerifyUserCodeRequest + +`func NewVerifyUserCodeRequest() *VerifyUserCodeRequest` + +NewVerifyUserCodeRequest instantiates a new VerifyUserCodeRequest object +This constructor will assign default values to properties that have it defined, +and makes sure properties required by API are set, but the set of arguments +will change when the set of required properties is changed + +### NewVerifyUserCodeRequestWithDefaults + +`func NewVerifyUserCodeRequestWithDefaults() *VerifyUserCodeRequest` + +NewVerifyUserCodeRequestWithDefaults instantiates a new VerifyUserCodeRequest object +This constructor will only assign default values to properties that have it defined, +but it doesn't guarantee that properties required by API are set + +### GetUserCode + +`func (o *VerifyUserCodeRequest) GetUserCode() string` + +GetUserCode returns the UserCode field if non-nil, zero value otherwise. + +### GetUserCodeOk + +`func (o *VerifyUserCodeRequest) GetUserCodeOk() (*string, bool)` + +GetUserCodeOk returns a tuple with the UserCode field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetUserCode + +`func (o *VerifyUserCodeRequest) SetUserCode(v string)` + +SetUserCode sets UserCode field to given value. + +### HasUserCode + +`func (o *VerifyUserCodeRequest) HasUserCode() bool` + +HasUserCode returns a boolean if a field has been set. + + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/internal/httpclient/model_device_authorization.go b/internal/httpclient/model_device_authorization.go new file mode 100644 index 00000000000..8aa32ff623b --- /dev/null +++ b/internal/httpclient/model_device_authorization.go @@ -0,0 +1,300 @@ +/* +Ory Hydra API + +Documentation for all of Ory Hydra's APIs. + +API version: +Contact: hi@ory.sh +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package openapi + +import ( + "encoding/json" +) + +// DeviceAuthorization # Ory's OAuth 2.0 Device Authorization API +type DeviceAuthorization struct { + // The device verification code. + DeviceCode *string `json:"device_code,omitempty"` + // The lifetime in seconds of the \"device_code\" and \"user_code\". + ExpiresIn *int64 `json:"expires_in,omitempty"` + // The minimum amount of time in seconds that the client SHOULD wait between polling requests to the token endpoint. If no value is provided, clients MUST use 5 as the default. + Interval *int64 `json:"interval,omitempty"` + // The end-user verification code. + UserCode *string `json:"user_code,omitempty"` + // The end-user verification URI on the authorization server. The URI should be short and easy to remember as end users will be asked to manually type it into their user agent. + VerificationUri *string `json:"verification_uri,omitempty"` + // A verification URI that includes the \"user_code\" (or other information with the same function as the \"user_code\"), which is designed for non-textual transmission. + VerificationUriComplete *string `json:"verification_uri_complete,omitempty"` +} + +// NewDeviceAuthorization instantiates a new DeviceAuthorization object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewDeviceAuthorization() *DeviceAuthorization { + this := DeviceAuthorization{} + return &this +} + +// NewDeviceAuthorizationWithDefaults instantiates a new DeviceAuthorization object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewDeviceAuthorizationWithDefaults() *DeviceAuthorization { + this := DeviceAuthorization{} + return &this +} + +// GetDeviceCode returns the DeviceCode field value if set, zero value otherwise. +func (o *DeviceAuthorization) GetDeviceCode() string { + if o == nil || o.DeviceCode == nil { + var ret string + return ret + } + return *o.DeviceCode +} + +// GetDeviceCodeOk returns a tuple with the DeviceCode field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DeviceAuthorization) GetDeviceCodeOk() (*string, bool) { + if o == nil || o.DeviceCode == nil { + return nil, false + } + return o.DeviceCode, true +} + +// HasDeviceCode returns a boolean if a field has been set. +func (o *DeviceAuthorization) HasDeviceCode() bool { + if o != nil && o.DeviceCode != nil { + return true + } + + return false +} + +// SetDeviceCode gets a reference to the given string and assigns it to the DeviceCode field. +func (o *DeviceAuthorization) SetDeviceCode(v string) { + o.DeviceCode = &v +} + +// GetExpiresIn returns the ExpiresIn field value if set, zero value otherwise. +func (o *DeviceAuthorization) GetExpiresIn() int64 { + if o == nil || o.ExpiresIn == nil { + var ret int64 + return ret + } + return *o.ExpiresIn +} + +// GetExpiresInOk returns a tuple with the ExpiresIn field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DeviceAuthorization) GetExpiresInOk() (*int64, bool) { + if o == nil || o.ExpiresIn == nil { + return nil, false + } + return o.ExpiresIn, true +} + +// HasExpiresIn returns a boolean if a field has been set. +func (o *DeviceAuthorization) HasExpiresIn() bool { + if o != nil && o.ExpiresIn != nil { + return true + } + + return false +} + +// SetExpiresIn gets a reference to the given int64 and assigns it to the ExpiresIn field. +func (o *DeviceAuthorization) SetExpiresIn(v int64) { + o.ExpiresIn = &v +} + +// GetInterval returns the Interval field value if set, zero value otherwise. +func (o *DeviceAuthorization) GetInterval() int64 { + if o == nil || o.Interval == nil { + var ret int64 + return ret + } + return *o.Interval +} + +// GetIntervalOk returns a tuple with the Interval field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DeviceAuthorization) GetIntervalOk() (*int64, bool) { + if o == nil || o.Interval == nil { + return nil, false + } + return o.Interval, true +} + +// HasInterval returns a boolean if a field has been set. +func (o *DeviceAuthorization) HasInterval() bool { + if o != nil && o.Interval != nil { + return true + } + + return false +} + +// SetInterval gets a reference to the given int64 and assigns it to the Interval field. +func (o *DeviceAuthorization) SetInterval(v int64) { + o.Interval = &v +} + +// GetUserCode returns the UserCode field value if set, zero value otherwise. +func (o *DeviceAuthorization) GetUserCode() string { + if o == nil || o.UserCode == nil { + var ret string + return ret + } + return *o.UserCode +} + +// GetUserCodeOk returns a tuple with the UserCode field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DeviceAuthorization) GetUserCodeOk() (*string, bool) { + if o == nil || o.UserCode == nil { + return nil, false + } + return o.UserCode, true +} + +// HasUserCode returns a boolean if a field has been set. +func (o *DeviceAuthorization) HasUserCode() bool { + if o != nil && o.UserCode != nil { + return true + } + + return false +} + +// SetUserCode gets a reference to the given string and assigns it to the UserCode field. +func (o *DeviceAuthorization) SetUserCode(v string) { + o.UserCode = &v +} + +// GetVerificationUri returns the VerificationUri field value if set, zero value otherwise. +func (o *DeviceAuthorization) GetVerificationUri() string { + if o == nil || o.VerificationUri == nil { + var ret string + return ret + } + return *o.VerificationUri +} + +// GetVerificationUriOk returns a tuple with the VerificationUri field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DeviceAuthorization) GetVerificationUriOk() (*string, bool) { + if o == nil || o.VerificationUri == nil { + return nil, false + } + return o.VerificationUri, true +} + +// HasVerificationUri returns a boolean if a field has been set. +func (o *DeviceAuthorization) HasVerificationUri() bool { + if o != nil && o.VerificationUri != nil { + return true + } + + return false +} + +// SetVerificationUri gets a reference to the given string and assigns it to the VerificationUri field. +func (o *DeviceAuthorization) SetVerificationUri(v string) { + o.VerificationUri = &v +} + +// GetVerificationUriComplete returns the VerificationUriComplete field value if set, zero value otherwise. +func (o *DeviceAuthorization) GetVerificationUriComplete() string { + if o == nil || o.VerificationUriComplete == nil { + var ret string + return ret + } + return *o.VerificationUriComplete +} + +// GetVerificationUriCompleteOk returns a tuple with the VerificationUriComplete field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DeviceAuthorization) GetVerificationUriCompleteOk() (*string, bool) { + if o == nil || o.VerificationUriComplete == nil { + return nil, false + } + return o.VerificationUriComplete, true +} + +// HasVerificationUriComplete returns a boolean if a field has been set. +func (o *DeviceAuthorization) HasVerificationUriComplete() bool { + if o != nil && o.VerificationUriComplete != nil { + return true + } + + return false +} + +// SetVerificationUriComplete gets a reference to the given string and assigns it to the VerificationUriComplete field. +func (o *DeviceAuthorization) SetVerificationUriComplete(v string) { + o.VerificationUriComplete = &v +} + +func (o DeviceAuthorization) MarshalJSON() ([]byte, error) { + toSerialize := map[string]interface{}{} + if o.DeviceCode != nil { + toSerialize["device_code"] = o.DeviceCode + } + if o.ExpiresIn != nil { + toSerialize["expires_in"] = o.ExpiresIn + } + if o.Interval != nil { + toSerialize["interval"] = o.Interval + } + if o.UserCode != nil { + toSerialize["user_code"] = o.UserCode + } + if o.VerificationUri != nil { + toSerialize["verification_uri"] = o.VerificationUri + } + if o.VerificationUriComplete != nil { + toSerialize["verification_uri_complete"] = o.VerificationUriComplete + } + return json.Marshal(toSerialize) +} + +type NullableDeviceAuthorization struct { + value *DeviceAuthorization + isSet bool +} + +func (v NullableDeviceAuthorization) Get() *DeviceAuthorization { + return v.value +} + +func (v *NullableDeviceAuthorization) Set(val *DeviceAuthorization) { + v.value = val + v.isSet = true +} + +func (v NullableDeviceAuthorization) IsSet() bool { + return v.isSet +} + +func (v *NullableDeviceAuthorization) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableDeviceAuthorization(val *DeviceAuthorization) *NullableDeviceAuthorization { + return &NullableDeviceAuthorization{value: val, isSet: true} +} + +func (v NullableDeviceAuthorization) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableDeviceAuthorization) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/internal/httpclient/model_device_grant_request.go b/internal/httpclient/model_device_grant_request.go new file mode 100644 index 00000000000..47f06fa000e --- /dev/null +++ b/internal/httpclient/model_device_grant_request.go @@ -0,0 +1,269 @@ +/* +Ory Hydra API + +Documentation for all of Ory Hydra's APIs. + +API version: +Contact: hi@ory.sh +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package openapi + +import ( + "encoding/json" + "time" +) + +// DeviceGrantRequest struct for DeviceGrantRequest +type DeviceGrantRequest struct { + // ID is the identifier (\"device challenge\") of the device grant request. It is used to identify the session. + Challenge string `json:"challenge"` + Client OAuth2Client `json:"client"` + HandledAt *time.Time `json:"handled_at,omitempty"` + // RequestURL is the original Device Grant URL requested. + RequestUrl *string `json:"request_url,omitempty"` + RequestedAccessTokenAudience []string `json:"requested_access_token_audience"` + RequestedScope []string `json:"requested_scope"` +} + +// NewDeviceGrantRequest instantiates a new DeviceGrantRequest object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewDeviceGrantRequest(challenge string, client OAuth2Client, requestedAccessTokenAudience []string, requestedScope []string) *DeviceGrantRequest { + this := DeviceGrantRequest{} + this.Challenge = challenge + this.Client = client + this.RequestedAccessTokenAudience = requestedAccessTokenAudience + this.RequestedScope = requestedScope + return &this +} + +// NewDeviceGrantRequestWithDefaults instantiates a new DeviceGrantRequest object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewDeviceGrantRequestWithDefaults() *DeviceGrantRequest { + this := DeviceGrantRequest{} + return &this +} + +// GetChallenge returns the Challenge field value +func (o *DeviceGrantRequest) GetChallenge() string { + if o == nil { + var ret string + return ret + } + + return o.Challenge +} + +// GetChallengeOk returns a tuple with the Challenge field value +// and a boolean to check if the value has been set. +func (o *DeviceGrantRequest) GetChallengeOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Challenge, true +} + +// SetChallenge sets field value +func (o *DeviceGrantRequest) SetChallenge(v string) { + o.Challenge = v +} + +// GetClient returns the Client field value +func (o *DeviceGrantRequest) GetClient() OAuth2Client { + if o == nil { + var ret OAuth2Client + return ret + } + + return o.Client +} + +// GetClientOk returns a tuple with the Client field value +// and a boolean to check if the value has been set. +func (o *DeviceGrantRequest) GetClientOk() (*OAuth2Client, bool) { + if o == nil { + return nil, false + } + return &o.Client, true +} + +// SetClient sets field value +func (o *DeviceGrantRequest) SetClient(v OAuth2Client) { + o.Client = v +} + +// GetHandledAt returns the HandledAt field value if set, zero value otherwise. +func (o *DeviceGrantRequest) GetHandledAt() time.Time { + if o == nil || o.HandledAt == nil { + var ret time.Time + return ret + } + return *o.HandledAt +} + +// GetHandledAtOk returns a tuple with the HandledAt field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DeviceGrantRequest) GetHandledAtOk() (*time.Time, bool) { + if o == nil || o.HandledAt == nil { + return nil, false + } + return o.HandledAt, true +} + +// HasHandledAt returns a boolean if a field has been set. +func (o *DeviceGrantRequest) HasHandledAt() bool { + if o != nil && o.HandledAt != nil { + return true + } + + return false +} + +// SetHandledAt gets a reference to the given time.Time and assigns it to the HandledAt field. +func (o *DeviceGrantRequest) SetHandledAt(v time.Time) { + o.HandledAt = &v +} + +// GetRequestUrl returns the RequestUrl field value if set, zero value otherwise. +func (o *DeviceGrantRequest) GetRequestUrl() string { + if o == nil || o.RequestUrl == nil { + var ret string + return ret + } + return *o.RequestUrl +} + +// GetRequestUrlOk returns a tuple with the RequestUrl field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DeviceGrantRequest) GetRequestUrlOk() (*string, bool) { + if o == nil || o.RequestUrl == nil { + return nil, false + } + return o.RequestUrl, true +} + +// HasRequestUrl returns a boolean if a field has been set. +func (o *DeviceGrantRequest) HasRequestUrl() bool { + if o != nil && o.RequestUrl != nil { + return true + } + + return false +} + +// SetRequestUrl gets a reference to the given string and assigns it to the RequestUrl field. +func (o *DeviceGrantRequest) SetRequestUrl(v string) { + o.RequestUrl = &v +} + +// GetRequestedAccessTokenAudience returns the RequestedAccessTokenAudience field value +func (o *DeviceGrantRequest) GetRequestedAccessTokenAudience() []string { + if o == nil { + var ret []string + return ret + } + + return o.RequestedAccessTokenAudience +} + +// GetRequestedAccessTokenAudienceOk returns a tuple with the RequestedAccessTokenAudience field value +// and a boolean to check if the value has been set. +func (o *DeviceGrantRequest) GetRequestedAccessTokenAudienceOk() ([]string, bool) { + if o == nil { + return nil, false + } + return o.RequestedAccessTokenAudience, true +} + +// SetRequestedAccessTokenAudience sets field value +func (o *DeviceGrantRequest) SetRequestedAccessTokenAudience(v []string) { + o.RequestedAccessTokenAudience = v +} + +// GetRequestedScope returns the RequestedScope field value +func (o *DeviceGrantRequest) GetRequestedScope() []string { + if o == nil { + var ret []string + return ret + } + + return o.RequestedScope +} + +// GetRequestedScopeOk returns a tuple with the RequestedScope field value +// and a boolean to check if the value has been set. +func (o *DeviceGrantRequest) GetRequestedScopeOk() ([]string, bool) { + if o == nil { + return nil, false + } + return o.RequestedScope, true +} + +// SetRequestedScope sets field value +func (o *DeviceGrantRequest) SetRequestedScope(v []string) { + o.RequestedScope = v +} + +func (o DeviceGrantRequest) MarshalJSON() ([]byte, error) { + toSerialize := map[string]interface{}{} + if true { + toSerialize["challenge"] = o.Challenge + } + if true { + toSerialize["client"] = o.Client + } + if o.HandledAt != nil { + toSerialize["handled_at"] = o.HandledAt + } + if o.RequestUrl != nil { + toSerialize["request_url"] = o.RequestUrl + } + if true { + toSerialize["requested_access_token_audience"] = o.RequestedAccessTokenAudience + } + if true { + toSerialize["requested_scope"] = o.RequestedScope + } + return json.Marshal(toSerialize) +} + +type NullableDeviceGrantRequest struct { + value *DeviceGrantRequest + isSet bool +} + +func (v NullableDeviceGrantRequest) Get() *DeviceGrantRequest { + return v.value +} + +func (v *NullableDeviceGrantRequest) Set(val *DeviceGrantRequest) { + v.value = val + v.isSet = true +} + +func (v NullableDeviceGrantRequest) IsSet() bool { + return v.isSet +} + +func (v *NullableDeviceGrantRequest) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableDeviceGrantRequest(val *DeviceGrantRequest) *NullableDeviceGrantRequest { + return &NullableDeviceGrantRequest{value: val, isSet: true} +} + +func (v NullableDeviceGrantRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableDeviceGrantRequest) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/internal/httpclient/model_oidc_configuration.go b/internal/httpclient/model_oidc_configuration.go index 08a0e7cd90a..b9f82af3b70 100644 --- a/internal/httpclient/model_oidc_configuration.go +++ b/internal/httpclient/model_oidc_configuration.go @@ -33,6 +33,8 @@ type OidcConfiguration struct { CredentialsEndpointDraft00 *string `json:"credentials_endpoint_draft_00,omitempty"` // OpenID Connect Verifiable Credentials Supported JSON array containing a list of the Verifiable Credentials supported by this authorization server. CredentialsSupportedDraft00 []CredentialSupportedDraft00 `json:"credentials_supported_draft_00,omitempty"` + // URL of the authorization server's device authorization endpoint + DeviceAuthorizationEndpoint *string `json:"device_authorization_endpoint,omitempty"` // OpenID Connect End-Session Endpoint URL at the OP to which an RP can perform a redirect to request that the End-User be logged out at the OP. EndSessionEndpoint *string `json:"end_session_endpoint,omitempty"` // OpenID Connect Front-Channel Logout Session Required Boolean value specifying whether the OP can pass iss (issuer) and sid (session ID) query parameters to identify the RP session with the OP when the frontchannel_logout_uri is used. If supported, the sid Claim is also included in ID Tokens issued by the OP. @@ -355,6 +357,38 @@ func (o *OidcConfiguration) SetCredentialsSupportedDraft00(v []CredentialSupport o.CredentialsSupportedDraft00 = v } +// GetDeviceAuthorizationEndpoint returns the DeviceAuthorizationEndpoint field value if set, zero value otherwise. +func (o *OidcConfiguration) GetDeviceAuthorizationEndpoint() string { + if o == nil || o.DeviceAuthorizationEndpoint == nil { + var ret string + return ret + } + return *o.DeviceAuthorizationEndpoint +} + +// GetDeviceAuthorizationEndpointOk returns a tuple with the DeviceAuthorizationEndpoint field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OidcConfiguration) GetDeviceAuthorizationEndpointOk() (*string, bool) { + if o == nil || o.DeviceAuthorizationEndpoint == nil { + return nil, false + } + return o.DeviceAuthorizationEndpoint, true +} + +// HasDeviceAuthorizationEndpoint returns a boolean if a field has been set. +func (o *OidcConfiguration) HasDeviceAuthorizationEndpoint() bool { + if o != nil && o.DeviceAuthorizationEndpoint != nil { + return true + } + + return false +} + +// SetDeviceAuthorizationEndpoint gets a reference to the given string and assigns it to the DeviceAuthorizationEndpoint field. +func (o *OidcConfiguration) SetDeviceAuthorizationEndpoint(v string) { + o.DeviceAuthorizationEndpoint = &v +} + // GetEndSessionEndpoint returns the EndSessionEndpoint field value if set, zero value otherwise. func (o *OidcConfiguration) GetEndSessionEndpoint() string { if o == nil || o.EndSessionEndpoint == nil { @@ -1053,6 +1087,9 @@ func (o OidcConfiguration) MarshalJSON() ([]byte, error) { if o.CredentialsSupportedDraft00 != nil { toSerialize["credentials_supported_draft_00"] = o.CredentialsSupportedDraft00 } + if o.DeviceAuthorizationEndpoint != nil { + toSerialize["device_authorization_endpoint"] = o.DeviceAuthorizationEndpoint + } if o.EndSessionEndpoint != nil { toSerialize["end_session_endpoint"] = o.EndSessionEndpoint } diff --git a/internal/httpclient/model_verify_user_code_request.go b/internal/httpclient/model_verify_user_code_request.go new file mode 100644 index 00000000000..514847a3082 --- /dev/null +++ b/internal/httpclient/model_verify_user_code_request.go @@ -0,0 +1,114 @@ +/* +Ory Hydra API + +Documentation for all of Ory Hydra's APIs. + +API version: +Contact: hi@ory.sh +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package openapi + +import ( + "encoding/json" +) + +// VerifyUserCodeRequest Contains information on an device verification +type VerifyUserCodeRequest struct { + UserCode *string `json:"user_code,omitempty"` +} + +// NewVerifyUserCodeRequest instantiates a new VerifyUserCodeRequest object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewVerifyUserCodeRequest() *VerifyUserCodeRequest { + this := VerifyUserCodeRequest{} + return &this +} + +// NewVerifyUserCodeRequestWithDefaults instantiates a new VerifyUserCodeRequest object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewVerifyUserCodeRequestWithDefaults() *VerifyUserCodeRequest { + this := VerifyUserCodeRequest{} + return &this +} + +// GetUserCode returns the UserCode field value if set, zero value otherwise. +func (o *VerifyUserCodeRequest) GetUserCode() string { + if o == nil || o.UserCode == nil { + var ret string + return ret + } + return *o.UserCode +} + +// GetUserCodeOk returns a tuple with the UserCode field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *VerifyUserCodeRequest) GetUserCodeOk() (*string, bool) { + if o == nil || o.UserCode == nil { + return nil, false + } + return o.UserCode, true +} + +// HasUserCode returns a boolean if a field has been set. +func (o *VerifyUserCodeRequest) HasUserCode() bool { + if o != nil && o.UserCode != nil { + return true + } + + return false +} + +// SetUserCode gets a reference to the given string and assigns it to the UserCode field. +func (o *VerifyUserCodeRequest) SetUserCode(v string) { + o.UserCode = &v +} + +func (o VerifyUserCodeRequest) MarshalJSON() ([]byte, error) { + toSerialize := map[string]interface{}{} + if o.UserCode != nil { + toSerialize["user_code"] = o.UserCode + } + return json.Marshal(toSerialize) +} + +type NullableVerifyUserCodeRequest struct { + value *VerifyUserCodeRequest + isSet bool +} + +func (v NullableVerifyUserCodeRequest) Get() *VerifyUserCodeRequest { + return v.value +} + +func (v *NullableVerifyUserCodeRequest) Set(val *VerifyUserCodeRequest) { + v.value = val + v.isSet = true +} + +func (v NullableVerifyUserCodeRequest) IsSet() bool { + return v.isSet +} + +func (v *NullableVerifyUserCodeRequest) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableVerifyUserCodeRequest(val *VerifyUserCodeRequest) *NullableVerifyUserCodeRequest { + return &NullableVerifyUserCodeRequest{value: val, isSet: true} +} + +func (v NullableVerifyUserCodeRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableVerifyUserCodeRequest) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/internal/mock/config_cookie.go b/internal/mock/config_cookie.go index 5fab6d1d7dc..e4e64002302 100644 --- a/internal/mock/config_cookie.go +++ b/internal/mock/config_cookie.go @@ -1,8 +1,8 @@ -// Copyright © 2022 Ory Corp +// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/ory/hydra/x (interfaces: CookieConfigProvider) +// Source: github.com/ory/hydra/v2/x (interfaces: CookieConfigProvider) // Package mock is a generated GoMock package. package mock diff --git a/jwk/registry_mock_test.go b/jwk/registry_mock_test.go index c305fd18167..14fc58247d2 100644 --- a/jwk/registry_mock_test.go +++ b/jwk/registry_mock_test.go @@ -1,4 +1,4 @@ -// Copyright © 2022 Ory Corp +// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 // Code generated by MockGen. DO NOT EDIT. @@ -13,7 +13,7 @@ import ( gomock "github.com/golang/mock/gomock" herodot "github.com/ory/herodot" - "github.com/ory/hydra/v2/aead" + aead "github.com/ory/hydra/v2/aead" config "github.com/ory/hydra/v2/driver/config" jwk "github.com/ory/hydra/v2/jwk" logrusx "github.com/ory/x/logrusx" diff --git a/oauth2/.snapshots/TestHandlerWellKnown-hsm_enabled=false.json b/oauth2/.snapshots/TestHandlerWellKnown-hsm_enabled=false.json index 215fa018214..6a7ca616431 100644 --- a/oauth2/.snapshots/TestHandlerWellKnown-hsm_enabled=false.json +++ b/oauth2/.snapshots/TestHandlerWellKnown-hsm_enabled=false.json @@ -35,6 +35,7 @@ ] } ], + "device_authorization_endpoint": "http://hydra.localhost/oauth2/device/auth", "end_session_endpoint": "http://hydra.localhost/oauth2/sessions/logout", "frontchannel_logout_session_supported": true, "frontchannel_logout_supported": true, @@ -42,7 +43,8 @@ "authorization_code", "implicit", "client_credentials", - "refresh_token" + "refresh_token", + "urn:ietf:params:oauth:grant-type:device_code" ], "id_token_signed_response_alg": [ "RS256" diff --git a/oauth2/.snapshots/TestHandlerWellKnown-hsm_enabled=true.json b/oauth2/.snapshots/TestHandlerWellKnown-hsm_enabled=true.json index 215fa018214..6a7ca616431 100644 --- a/oauth2/.snapshots/TestHandlerWellKnown-hsm_enabled=true.json +++ b/oauth2/.snapshots/TestHandlerWellKnown-hsm_enabled=true.json @@ -35,6 +35,7 @@ ] } ], + "device_authorization_endpoint": "http://hydra.localhost/oauth2/device/auth", "end_session_endpoint": "http://hydra.localhost/oauth2/sessions/logout", "frontchannel_logout_session_supported": true, "frontchannel_logout_supported": true, @@ -42,7 +43,8 @@ "authorization_code", "implicit", "client_credentials", - "refresh_token" + "refresh_token", + "urn:ietf:params:oauth:grant-type:device_code" ], "id_token_signed_response_alg": [ "RS256" diff --git a/oauth2/handler.go b/oauth2/handler.go index 5662be5cc8e..b453ff0ee60 100644 --- a/oauth2/handler.go +++ b/oauth2/handler.go @@ -45,6 +45,8 @@ const ( DefaultConsentPath = "/oauth2/fallbacks/consent" DefaultPostLogoutPath = "/oauth2/fallbacks/logout/callback" DefaultLogoutPath = "/oauth2/fallbacks/logout" + DefaultDevicePath = "/oauth2/fallbacks/device" + DefaultPostDevicePath = "/oauth2/fallbacks/device/done" DefaultErrorPath = "/oauth2/fallbacks/error" TokenPath = "/oauth2/token" // #nosec G101 AuthPath = "/oauth2/auth" @@ -59,6 +61,9 @@ const ( IntrospectPath = "/oauth2/introspect" RevocationPath = "/oauth2/revoke" DeleteTokensPath = "/oauth2/tokens" // #nosec G101 + + // Device Grant Handler + DeviceAuthPath = "/oauth2/device/auth" ) type Handler struct { @@ -85,6 +90,13 @@ func (h *Handler) SetRoutes(admin *httprouterx.RouterAdmin, public *httprouterx. public.GET(DefaultLoginPath, h.fallbackHandler("", "", http.StatusOK, config.KeyLoginURL)) public.GET(DefaultConsentPath, h.fallbackHandler("", "", http.StatusOK, config.KeyConsentURL)) public.GET(DefaultLogoutPath, h.fallbackHandler("", "", http.StatusOK, config.KeyLogoutURL)) + public.GET(DefaultDevicePath, h.fallbackHandler("", "", http.StatusOK, config.KeyDeviceURL)) + public.GET(DefaultPostDevicePath, h.fallbackHandler( + "You successfully authenticated on your device!", + "The Default Post Device URL is not set which is why you are seeing this fallback page. Your device login request however succeeded.", + http.StatusOK, + config.KeyDeviceDoneURL, + )) public.GET(DefaultPostLogoutPath, h.fallbackHandler( "You logged out successfully!", "The Default Post Logout URL is not set which is why you are seeing this fallback page. Your log out request however succeeded.", @@ -106,6 +118,209 @@ func (h *Handler) SetRoutes(admin *httprouterx.RouterAdmin, public *httprouterx. admin.POST(IntrospectPath, h.introspectOAuth2Token) admin.DELETE(DeleteTokensPath, h.deleteOAuth2Token) + + public.GET(DeviceAuthPath, h.performOAuth2DeviceAuthorizationFlow) + // This endpoint should be call on the device side + public.POST(DeviceAuthPath, h.performOAuth2DeviceFlow) +} + +// FIXME: Add Doc +func (h *Handler) performOAuth2DeviceAuthorizationFlow(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + var ctx = r.Context() + + authorizeRequest, err := h.r.OAuth2Provider().NewDeviceUserRequest(ctx, r) + if err != nil { + x.LogError(r, err, h.r.Logger()) + return + } + + session, flow, err := h.r.ConsentStrategy().HandleOAuth2DeviceAuthorizationRequest(ctx, w, r, authorizeRequest) + if errors.Is(err, consent.ErrAbortOAuth2Request) { + x.LogAudit(r, nil, h.r.AuditLogger()) + // do nothing + return + } else if e := &(fosite.RFC6749Error{}); errors.As(err, &e) { + x.LogAudit(r, err, h.r.AuditLogger()) + h.r.Writer().WriteError(w, r, err) + return + } else if err != nil { + x.LogError(r, err, h.r.Logger()) + h.r.Writer().WriteError(w, r, err) + return + } + + authorizeRequest.SetRequestedScopes(fosite.Arguments(session.ConsentRequest.RequestedScope)) + for _, scope := range session.GrantedScope { + authorizeRequest.GrantScope(scope) + } + + authorizeRequest.SetRequestedAudience(fosite.Arguments(session.ConsentRequest.RequestedAudience)) + for _, audience := range session.GrantedAudience { + authorizeRequest.GrantAudience(audience) + } + + openIDKeyID, err := h.r.OpenIDJWTStrategy().GetPublicKeyID(ctx) + if err != nil { + x.LogError(r, err, h.r.Logger()) + h.r.Writer().WriteError(w, r, err) + return + } + + var accessTokenKeyID string + if h.c.AccessTokenStrategy(r.Context()) == "jwt" { + accessTokenKeyID, err = h.r.AccessTokenJWTStrategy().GetPublicKeyID(ctx) + if err != nil { + x.LogError(r, err, h.r.Logger()) + h.r.Writer().WriteError(w, r, err) + return + } + } + + obfuscatedSubject, err := h.r.ConsentStrategy().ObfuscateSubjectIdentifier(ctx, authorizeRequest.GetClient(), session.ConsentRequest.Subject, session.ConsentRequest.ForceSubjectIdentifier) + if e := &(fosite.RFC6749Error{}); errors.As(err, &e) { + x.LogAudit(r, err, h.r.AuditLogger()) + h.r.Writer().WriteError(w, r, err) + return + } else if err != nil { + x.LogError(r, err, h.r.Logger()) + h.r.Writer().WriteError(w, r, err) + return + } + + authorizeRequest.SetID(session.ID) + claims := &jwt.IDTokenClaims{ + Subject: obfuscatedSubject, + Issuer: h.c.IssuerURL(ctx).String(), + AuthTime: time.Time(session.AuthenticatedAt), + RequestedAt: session.RequestedAt, + Extra: session.Session.IDToken, + AuthenticationContextClassReference: session.ConsentRequest.ACR, + AuthenticationMethodsReferences: session.ConsentRequest.AMR, + + // These are required for work around https://github.com/ory/fosite/issues/530 + Nonce: authorizeRequest.GetRequestForm().Get("nonce"), + Audience: []string{authorizeRequest.GetClient().GetID()}, + IssuedAt: time.Now().Truncate(time.Second).UTC(), + + // This is set by the fosite strategy + // ExpiresAt: time.Now().Add(h.IDTokenLifespan).UTC(), + } + claims.Add("sid", session.ConsentRequest.LoginSessionID) + + // done + response, err := h.r.OAuth2Provider().NewDeviceUserResponse(ctx, authorizeRequest, &Session{ + DefaultSession: &openid.DefaultSession{ + Claims: claims, + Headers: &jwt.Headers{Extra: map[string]interface{}{ + // required for lookup on jwk endpoint + "kid": openIDKeyID, + }}, + Subject: session.ConsentRequest.Subject, + }, + Extra: session.Session.AccessToken, + KID: accessTokenKeyID, + ClientID: authorizeRequest.GetClient().GetID(), + ConsentChallenge: session.ID, + ExcludeNotBeforeClaim: h.c.ExcludeNotBeforeClaim(ctx), + AllowedTopLevelClaims: h.c.AllowedTopLevelClaims(ctx), + Flow: flow, + }) + if err != nil { + x.LogError(r, err, h.r.Logger()) + h.r.Writer().WriteError(w, r, err) + return + } + + err = h.r.OAuth2Storage().UpdateDeviceCodeSession(ctx, authorizeRequest.GetDeviceCodeSignature(), authorizeRequest) + if err != nil { + x.LogError(r, err, h.r.Logger()) + h.r.Writer().WriteError(w, r, err) + } + + h.r.OAuth2Provider().WriteDeviceUserResponse(ctx, r, w, authorizeRequest, response) +} + +// OAuth2 Device Flow +// +// # Ory's OAuth 2.0 Device Authorization API +// +// swagger:model deviceAuthorization +type deviceAuthorization struct { + // The device verification code. + // + // example: ory_dc_smldfksmdfkl.mslkmlkmlk + DeviceCode string `json:"device_code"` + + // The end-user verification code. + // + // example: AAAAAA + UserCode string `json:"user_code"` + + // The end-user verification URI on the authorization + // server. The URI should be short and easy to remember as end users + // will be asked to manually type it into their user agent. + // + // example: https://auth.ory.sh/tv + VerificationUri string `json:"verification_uri"` + + // A verification URI that includes the "user_code" (or + // other information with the same function as the "user_code"), + // which is designed for non-textual transmission. + // + // example: https://auth.ory.sh/tv?user_code=AAAAAA + VerificationUriComplete string `json:"verification_uri_complete"` + + // The lifetime in seconds of the "device_code" and "user_code". + // + // example: 16830 + ExpiresIn int `json:"expires_in"` + + // The minimum amount of time in seconds that the client + // SHOULD wait between polling requests to the token endpoint. If no + // value is provided, clients MUST use 5 as the default. + // + // example: 5 + Interval int `json:"interval"` +} + +// swagger:route POST /oauth2/device/auth v0alpha2 performOAuth2DeviceFlow +// +// # The OAuth 2.0 Device Authorize Endpoint +// +// This endpoint is not documented here because you should never use your own implementation to perform OAuth2 flows. +// OAuth2 is a very popular protocol and a library for your programming language will exists. +// +// To learn more about this flow please refer to the specification: https://tools.ietf.org/html/rfc8628 +// +// Consumes: +// - application/x-www-form-urlencoded +// +// Schemes: http, https +// +// Responses: +// 200: deviceAuthorization +// default: errorOAuth2 +func (h *Handler) performOAuth2DeviceFlow(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + var ctx = r.Context() + request, err := h.r.OAuth2Provider().NewDeviceRequest(ctx, r) + if err != nil { + h.r.Writer().WriteError(w, r, err) + return + } + + var session = &Session{ + DefaultSession: &openid.DefaultSession{ + Headers: &jwt.Headers{}}, + } + + request.SetSession(session) + resp, err := h.r.OAuth2Provider().NewDeviceResponse(ctx, request) + if err != nil { + h.r.Writer().WriteError(w, r, err) + return + } + + h.r.OAuth2Provider().WriteDeviceResponse(ctx, w, request, resp) } // swagger:route GET /oauth2/sessions/logout oidc revokeOidcSession @@ -255,6 +470,9 @@ type oidcConfiguration struct { // example: https://playground.ory.sh/ory-hydra/public/oauth2/token TokenURL string `json:"token_endpoint"` + // URL of the authorization server's device authorization endpoint + DeviceAuthorisationEndpoint string `json:"device_authorization_endpoint"` + // OpenID Connect Well-Known JSON Web Keys URL // // URL of the OP's JSON Web Key Set [JWK] document. This contains the signing key(s) the RP uses to validate @@ -485,6 +703,7 @@ func (h *Handler) discoverOidcConfiguration(w http.ResponseWriter, r *http.Reque JWKsURI: h.c.JWKSURL(ctx).String(), RevocationEndpoint: urlx.AppendPaths(h.c.IssuerURL(ctx), RevocationPath).String(), RegistrationEndpoint: h.c.OAuth2ClientRegistrationURL(ctx).String(), + DeviceAuthorisationEndpoint: h.c.OAuth2DeviceAuthorisationURL(ctx).String(), SubjectTypes: h.c.SubjectTypesSupported(ctx), ResponseTypes: []string{"code", "code id_token", "id_token", "token id_token", "token", "token id_token code"}, ClaimsSupported: h.c.OIDCDiscoverySupportedClaims(ctx), @@ -494,7 +713,7 @@ func (h *Handler) discoverOidcConfiguration(w http.ResponseWriter, r *http.Reque IDTokenSigningAlgValuesSupported: []string{key.Algorithm}, IDTokenSignedResponseAlg: []string{key.Algorithm}, UserinfoSignedResponseAlg: []string{key.Algorithm}, - GrantTypesSupported: []string{"authorization_code", "implicit", "client_credentials", "refresh_token"}, + GrantTypesSupported: []string{"authorization_code", "implicit", "client_credentials", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"}, ResponseModesSupported: []string{"query", "fragment"}, UserinfoSigningAlgValuesSupported: []string{"none", key.Algorithm}, RequestParameterSupported: true, diff --git a/oauth2/oauth2_helper_test.go b/oauth2/oauth2_helper_test.go index 52a30e5975e..0a9767f7af6 100644 --- a/oauth2/oauth2_helper_test.go +++ b/oauth2/oauth2_helper_test.go @@ -54,6 +54,26 @@ func (c *consentMock) HandleHeadlessLogout(ctx context.Context, w http.ResponseW panic("not implemented") } +func (c *consentMock) HandleOAuth2DeviceAuthorizationRequest(ctx context.Context, w http.ResponseWriter, r *http.Request, req fosite.DeviceUserRequester) (*flow.AcceptOAuth2ConsentRequest, *flow.Flow, error) { + if c.deny { + return nil, nil, fosite.ErrRequestForbidden + } + + return &flow.AcceptOAuth2ConsentRequest{ + ConsentRequest: &flow.OAuth2ConsentRequest{ + Subject: "foo", + ACR: "1", + }, + AuthenticatedAt: sqlxx.NullTime(c.authTime), + GrantedScope: []string{"offline", "openid", "hydra.*"}, + Session: &flow.AcceptOAuth2ConsentRequestSession{ + AccessToken: map[string]interface{}{}, + IDToken: map[string]interface{}{}, + }, + RequestedAt: c.requestTime, + }, nil, nil +} + func (c *consentMock) ObfuscateSubjectIdentifier(ctx context.Context, cl fosite.Client, subject, forcedIdentifier string) (string, error) { if c, ok := cl.(*client.Client); ok && c.SubjectType == "pairwise" { panic("not implemented") diff --git a/oauth2/oauth2_provider_mock_test.go b/oauth2/oauth2_provider_mock_test.go index 83d584eb12f..a4a69d860b1 100644 --- a/oauth2/oauth2_provider_mock_test.go +++ b/oauth2/oauth2_provider_mock_test.go @@ -1,4 +1,4 @@ -// Copyright © 2022 Ory Corp +// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 // Code generated by MockGen. DO NOT EDIT. @@ -121,6 +121,66 @@ func (mr *MockOAuth2ProviderMockRecorder) NewAuthorizeResponse(arg0, arg1, arg2 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewAuthorizeResponse", reflect.TypeOf((*MockOAuth2Provider)(nil).NewAuthorizeResponse), arg0, arg1, arg2) } +// NewDeviceUserRequest mocks base method. +func (m *MockOAuth2Provider) NewDeviceUserRequest(arg0 context.Context, arg1 *http.Request) (fosite.DeviceUserRequester, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewDeviceUserRequest", arg0, arg1) + ret0, _ := ret[0].(fosite.DeviceUserRequester) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NewDeviceUserRequest indicates an expected call of NewDeviceUserRequest. +func (mr *MockOAuth2ProviderMockRecorder) NewDeviceUserRequest(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewDeviceUserRequest", reflect.TypeOf((*MockOAuth2Provider)(nil).NewDeviceUserRequest), arg0, arg1) +} + +// NewDeviceUserResponse mocks base method. +func (m *MockOAuth2Provider) NewDeviceUserResponse(arg0 context.Context, arg1 fosite.DeviceUserRequester, arg2 fosite.Session) (fosite.DeviceUserResponder, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewDeviceUserResponse", arg0, arg1, arg2) + ret0, _ := ret[0].(fosite.DeviceUserResponder) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NewDeviceUserResponse indicates an expected call of NewDeviceUserResponse. +func (mr *MockOAuth2ProviderMockRecorder) NewDeviceUserResponse(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewDeviceUserResponse", reflect.TypeOf((*MockOAuth2Provider)(nil).NewDeviceUserResponse), arg0, arg1, arg2) +} + +// NewDeviceRequest mocks base method. +func (m *MockOAuth2Provider) NewDeviceRequest(arg0 context.Context, arg1 *http.Request) (fosite.DeviceRequester, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewDeviceRequest", arg0, arg1) + ret0, _ := ret[0].(fosite.DeviceRequester) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NewDeviceRequest indicates an expected call of NewDeviceRequest. +func (mr *MockOAuth2ProviderMockRecorder) NewDeviceRequest(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewDeviceRequest", reflect.TypeOf((*MockOAuth2Provider)(nil).NewDeviceRequest), arg0, arg1) +} + +// NewDeviceResponse mocks base method. +func (m *MockOAuth2Provider) NewDeviceResponse(arg0 context.Context, arg1 fosite.DeviceRequester) (fosite.DeviceResponder, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewDeviceResponse", arg0, arg1) + ret0, _ := ret[0].(fosite.DeviceResponder) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NewDeviceResponse indicates an expected call of NewDeviceResponse. +func (mr *MockOAuth2ProviderMockRecorder) NewDeviceResponse(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewDeviceResponse", reflect.TypeOf((*MockOAuth2Provider)(nil).NewDeviceResponse), arg0, arg1) +} + // NewIntrospectionRequest mocks base method. func (m *MockOAuth2Provider) NewIntrospectionRequest(arg0 context.Context, arg1 *http.Request, arg2 fosite.Session) (fosite.IntrospectionResponder, error) { m.ctrl.T.Helper() @@ -228,6 +288,30 @@ func (mr *MockOAuth2ProviderMockRecorder) WriteAuthorizeResponse(arg0, arg1, arg return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WriteAuthorizeResponse", reflect.TypeOf((*MockOAuth2Provider)(nil).WriteAuthorizeResponse), arg0, arg1, arg2, arg3) } +// WriteDeviceUserResponse mocks base method. +func (m *MockOAuth2Provider) WriteDeviceUserResponse(arg0 context.Context, arg1 *http.Request, arg2 http.ResponseWriter, arg3 fosite.DeviceUserRequester, arg4 fosite.DeviceUserResponder) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "WriteDeviceUserResponse", arg0, arg1, arg2, arg3, arg4) +} + +// WriteDeviceUserResponse indicates an expected call of WriteDeviceUserResponse. +func (mr *MockOAuth2ProviderMockRecorder) WriteDeviceUserResponse(arg0, arg1, arg2, arg3, arg4 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WriteDeviceUserResponse", reflect.TypeOf((*MockOAuth2Provider)(nil).WriteDeviceUserResponse), arg0, arg1, arg2, arg3, arg4) +} + +// WriteDeviceResponse mocks base method. +func (m *MockOAuth2Provider) WriteDeviceResponse(arg0 context.Context, arg1 http.ResponseWriter, arg2 fosite.DeviceRequester, arg3 fosite.DeviceResponder) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "WriteDeviceResponse", arg0, arg1, arg2, arg3) +} + +// WriteDeviceResponse indicates an expected call of WriteDeviceResponse. +func (mr *MockOAuth2ProviderMockRecorder) WriteDeviceResponse(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WriteDeviceResponse", reflect.TypeOf((*MockOAuth2Provider)(nil).WriteDeviceResponse), arg0, arg1, arg2, arg3) +} + // WriteIntrospectionError mocks base method. func (m *MockOAuth2Provider) WriteIntrospectionError(arg0 context.Context, arg1 http.ResponseWriter, arg2 error) { m.ctrl.T.Helper() diff --git a/persistence/sql/migrations/20220823111500000000_support_device_grants.cockroach.up.sql b/persistence/sql/migrations/20220823111500000000_support_device_grants.cockroach.up.sql new file mode 100644 index 00000000000..172adae0e71 --- /dev/null +++ b/persistence/sql/migrations/20220823111500000000_support_device_grants.cockroach.up.sql @@ -0,0 +1,47 @@ +CREATE TABLE IF NOT EXISTS hydra_oauth2_device_code +( + signature VARCHAR(255) NOT NULL PRIMARY KEY, + request_id VARCHAR(255) NOT NULL, + requested_at TIMESTAMP NOT NULL DEFAULT NOW(), + client_id VARCHAR(255) NOT NULL, + scope TEXT NOT NULL, + granted_scope TEXT NOT NULL, + form_data TEXT NOT NULL, + session_data TEXT NOT NULL, + subject VARCHAR(255) NOT NULL DEFAULT '', + active BOOL NOT NULL DEFAULT true, + requested_audience TEXT NULL DEFAULT '', + granted_audience TEXT NULL DEFAULT '', + challenge_id VARCHAR(40) NULL, + nid UUID NULL +); +CREATE INDEX hydra_oauth2_device_code_request_id_idx ON hydra_oauth2_device_code (request_id, nid); +CREATE INDEX hydra_oauth2_device_code_client_id_idx ON hydra_oauth2_device_code (client_id, nid); +CREATE INDEX hydra_oauth2_device_code_challenge_id_idx ON hydra_oauth2_device_code (challenge_id, nid); +ALTER TABLE hydra_oauth2_device_code ADD CONSTRAINT hydra_oauth2_device_code_challenge_id_fk FOREIGN KEY (challenge_id) REFERENCES hydra_oauth2_flow(consent_challenge_id) ON DELETE CASCADE; +ALTER TABLE hydra_oauth2_device_code ADD CONSTRAINT hydra_oauth2_device_code_client_id_fk FOREIGN KEY (client_id, nid) REFERENCES hydra_client(id, nid) ON DELETE CASCADE; +ALTER TABLE hydra_oauth2_device_code ADD CONSTRAINT hydra_oauth2_device_code_nid_fk_idx FOREIGN KEY (nid) REFERENCES networks(id) ON UPDATE RESTRICT ON DELETE CASCADE; + +CREATE TABLE IF NOT EXISTS hydra_oauth2_user_code +( + signature VARCHAR(255) NOT NULL PRIMARY KEY, + request_id VARCHAR(40) NOT NULL, + requested_at TIMESTAMP NOT NULL DEFAULT NOW(), + client_id VARCHAR(255) NOT NULL, + scope TEXT NOT NULL, + granted_scope TEXT NOT NULL, + form_data TEXT NOT NULL, + session_data TEXT NOT NULL, + subject VARCHAR(255) NOT NULL DEFAULT '', + active BOOL NOT NULL DEFAULT true, + requested_audience TEXT NULL DEFAULT '', + granted_audience TEXT NULL DEFAULT '', + challenge_id VARCHAR(40) NULL, + nid UUID NULL +); +CREATE INDEX hydra_oauth2_user_code_request_id_idx ON hydra_oauth2_user_code (request_id, nid); +CREATE INDEX hydra_oauth2_user_code_client_id_idx ON hydra_oauth2_user_code (client_id, nid); +CREATE INDEX hydra_oauth2_user_code_challenge_id_idx ON hydra_oauth2_user_code (challenge_id, nid); +ALTER TABLE hydra_oauth2_user_code ADD CONSTRAINT hydra_oauth2_user_code_challenge_id_fk FOREIGN KEY (challenge_id) REFERENCES hydra_oauth2_flow(consent_challenge_id) ON DELETE CASCADE; +ALTER TABLE hydra_oauth2_user_code ADD CONSTRAINT hydra_oauth2_user_code_client_id_fk FOREIGN KEY (client_id, nid) REFERENCES hydra_client(id, nid) ON DELETE CASCADE; +ALTER TABLE hydra_oauth2_user_code ADD CONSTRAINT hydra_oauth2_user_code_nid_fk_idx FOREIGN KEY (nid) REFERENCES networks(id) ON UPDATE RESTRICT ON DELETE CASCADE; diff --git a/persistence/sql/migrations/20220823111500000000_support_device_grants.down.sql b/persistence/sql/migrations/20220823111500000000_support_device_grants.down.sql new file mode 100644 index 00000000000..c331749dacf --- /dev/null +++ b/persistence/sql/migrations/20220823111500000000_support_device_grants.down.sql @@ -0,0 +1,21 @@ +ALTER TABLE + hydra_oauth2_device_code DROP FOREIGN KEY IF EXISTS hydra_oauth2_device_code_challenge_id_fk; + +ALTER TABLE + hydra_oauth2_device_code DROP FOREIGN KEY IF EXISTS hydra_oauth2_device_code_client_id_fk; + +ALTER TABLE + hydra_oauth2_device_code DROP FOREIGN KEY IF EXISTS hydra_oauth2_device_code_nid_fk_idx; + +DROP TABLE IF EXISTS hydra_oauth2_device_code; + +ALTER TABLE + hydra_oauth2_user_code DROP FOREIGN KEY IF EXISTS hydra_oauth2_user_code_challenge_id_fk; + +ALTER TABLE + hydra_oauth2_user_code DROP FOREIGN KEY IF EXISTS hydra_oauth2_user_code_client_id_fk; + +ALTER TABLE + hydra_oauth2_user_code DROP FOREIGN KEY IF EXISTS hydra_oauth2_user_code_nid_fk_idx; + +DROP TABLE IF EXISTS hydra_oauth2_user_code; \ No newline at end of file diff --git a/persistence/sql/migrations/20220823111500000000_support_device_grants.mysql.up.sql b/persistence/sql/migrations/20220823111500000000_support_device_grants.mysql.up.sql new file mode 100644 index 00000000000..2212460c782 --- /dev/null +++ b/persistence/sql/migrations/20220823111500000000_support_device_grants.mysql.up.sql @@ -0,0 +1,49 @@ +CREATE TABLE IF NOT EXISTS hydra_oauth2_device_code +( + signature VARCHAR(255) NOT NULL PRIMARY KEY, + request_id VARCHAR(255) NOT NULL DEFAULT '', + requested_at TIMESTAMP NOT NULL DEFAULT NOW(), + client_id VARCHAR(255) NOT NULL DEFAULT '', + scope TEXT NOT NULL, + granted_scope TEXT NOT NULL, + form_data TEXT NOT NULL, + session_data TEXT NOT NULL, + subject VARCHAR(255) NOT NULL DEFAULT '', + active BOOL NOT NULL DEFAULT true, + requested_audience TEXT NOT NULL, + granted_audience TEXT NOT NULL, + challenge_id VARCHAR(40) NULL, + nid CHAR(36) NOT NULL +); +CREATE INDEX hydra_oauth2_device_code_nid_fk_idx ON hydra_oauth2_device_code (nid); +CREATE INDEX hydra_oauth2_device_code_request_id_idx ON hydra_oauth2_device_code (request_id, nid); +CREATE INDEX hydra_oauth2_device_code_client_id_idx ON hydra_oauth2_device_code (client_id, nid); +CREATE INDEX hydra_oauth2_device_code_challenge_id_idx ON hydra_oauth2_device_code (challenge_id); +ALTER TABLE hydra_oauth2_device_code ADD CONSTRAINT hydra_oauth2_device_code_challenge_id_fk FOREIGN KEY (challenge_id) REFERENCES hydra_oauth2_flow(consent_challenge_id) ON DELETE CASCADE; +ALTER TABLE hydra_oauth2_device_code ADD CONSTRAINT hydra_oauth2_device_code_client_id_fk FOREIGN KEY (client_id, nid) REFERENCES hydra_client(id, nid) ON DELETE CASCADE; +ALTER TABLE hydra_oauth2_device_code ADD CONSTRAINT hydra_oauth2_device_code_nid_fk_idx FOREIGN KEY (nid) REFERENCES networks(id) ON UPDATE RESTRICT ON DELETE CASCADE; + +CREATE TABLE IF NOT EXISTS hydra_oauth2_user_code +( + signature VARCHAR(255) NOT NULL PRIMARY KEY, + request_id VARCHAR(40) NOT NULL DEFAULT '', + requested_at TIMESTAMP NOT NULL DEFAULT NOW(), + client_id VARCHAR(255) NOT NULL DEFAULT '', + scope TEXT NOT NULL, + granted_scope TEXT NOT NULL, + form_data TEXT NOT NULL, + session_data TEXT NOT NULL, + subject VARCHAR(255) NOT NULL DEFAULT '', + active BOOL NOT NULL DEFAULT true, + requested_audience TEXT NOT NULL, + granted_audience TEXT NOT NULL, + challenge_id VARCHAR(40) NULL, + nid CHAR(36) NOT NULL +); +CREATE INDEX hydra_oauth2_user_code_nid_fk_idx ON hydra_oauth2_user_code (nid); +CREATE INDEX hydra_oauth2_user_code_request_id_idx ON hydra_oauth2_user_code (request_id, nid); +CREATE INDEX hydra_oauth2_user_code_client_id_idx ON hydra_oauth2_user_code (client_id, nid); +CREATE INDEX hydra_oauth2_user_code_challenge_id_idx ON hydra_oauth2_user_code (challenge_id); +ALTER TABLE hydra_oauth2_user_code ADD CONSTRAINT hydra_oauth2_user_code_challenge_id_fk FOREIGN KEY (challenge_id) REFERENCES hydra_oauth2_flow(consent_challenge_id) ON DELETE CASCADE; +ALTER TABLE hydra_oauth2_user_code ADD CONSTRAINT hydra_oauth2_user_code_client_id_fk FOREIGN KEY (client_id, nid) REFERENCES hydra_client(id, nid) ON DELETE CASCADE; +ALTER TABLE hydra_oauth2_user_code ADD CONSTRAINT hydra_oauth2_user_code_nid_fk_idx FOREIGN KEY (nid) REFERENCES networks(id) ON UPDATE RESTRICT ON DELETE CASCADE; diff --git a/persistence/sql/migrations/20220823111500000000_support_device_grants.postgres.up.sql b/persistence/sql/migrations/20220823111500000000_support_device_grants.postgres.up.sql new file mode 100644 index 00000000000..8117f70554b --- /dev/null +++ b/persistence/sql/migrations/20220823111500000000_support_device_grants.postgres.up.sql @@ -0,0 +1,47 @@ +CREATE TABLE IF NOT EXISTS hydra_oauth2_device_code +( + signature VARCHAR(255) NOT NULL PRIMARY KEY, + request_id VARCHAR(40) NOT NULL, + requested_at TIMESTAMP NOT NULL DEFAULT NOW(), + client_id VARCHAR(255) NOT NULL, + scope TEXT NOT NULL, + granted_scope TEXT NOT NULL, + form_data TEXT NOT NULL, + session_data TEXT NOT NULL, + subject VARCHAR(255) NOT NULL DEFAULT '', + active BOOL NOT NULL DEFAULT true, + requested_audience TEXT NULL DEFAULT '', + granted_audience TEXT NULL DEFAULT '', + challenge_id VARCHAR(40) NULL, + nid UUID NULL +); +CREATE INDEX hydra_oauth2_device_code_request_id_idx ON hydra_oauth2_device_code (request_id, nid); +CREATE INDEX hydra_oauth2_device_code_client_id_idx ON hydra_oauth2_device_code (client_id, nid); +CREATE INDEX hydra_oauth2_device_code_challenge_id_idx ON hydra_oauth2_device_code (challenge_id, nid); +ALTER TABLE hydra_oauth2_device_code ADD CONSTRAINT hydra_oauth2_device_code_challenge_id_fk FOREIGN KEY (challenge_id) REFERENCES hydra_oauth2_flow(consent_challenge_id) ON DELETE CASCADE; +ALTER TABLE hydra_oauth2_device_code ADD CONSTRAINT hydra_oauth2_device_code_client_id_fk FOREIGN KEY (client_id, nid) REFERENCES hydra_client(id, nid) ON DELETE CASCADE; +ALTER TABLE hydra_oauth2_device_code ADD CONSTRAINT hydra_oauth2_device_code_nid_fk_idx FOREIGN KEY (nid) REFERENCES networks(id) ON UPDATE RESTRICT ON DELETE CASCADE; + +CREATE TABLE IF NOT EXISTS hydra_oauth2_user_code +( + signature VARCHAR(255) NOT NULL PRIMARY KEY, + request_id VARCHAR(255) NOT NULL, + requested_at TIMESTAMP NOT NULL DEFAULT NOW(), + client_id VARCHAR(255) NOT NULL, + scope TEXT NOT NULL, + granted_scope TEXT NOT NULL, + form_data TEXT NOT NULL, + session_data TEXT NOT NULL, + subject VARCHAR(255) NOT NULL DEFAULT '', + active BOOL NOT NULL DEFAULT true, + requested_audience TEXT NULL DEFAULT '', + granted_audience TEXT NULL DEFAULT '', + challenge_id VARCHAR(40) NULL, + nid UUID NULL +); +CREATE INDEX hydra_oauth2_user_code_request_id_idx ON hydra_oauth2_user_code (request_id, nid); +CREATE INDEX hydra_oauth2_user_code_client_id_idx ON hydra_oauth2_user_code (client_id, nid); +CREATE INDEX hydra_oauth2_user_code_challenge_id_idx ON hydra_oauth2_user_code (challenge_id, nid); +ALTER TABLE hydra_oauth2_user_code ADD CONSTRAINT hydra_oauth2_user_code_challenge_id_fk FOREIGN KEY (challenge_id) REFERENCES hydra_oauth2_flow(consent_challenge_id) ON DELETE CASCADE; +ALTER TABLE hydra_oauth2_user_code ADD CONSTRAINT hydra_oauth2_user_code_client_id_fk FOREIGN KEY (client_id, nid) REFERENCES hydra_client(id, nid) ON DELETE CASCADE; +ALTER TABLE hydra_oauth2_user_code ADD CONSTRAINT hydra_oauth2_user_code_nid_fk_idx FOREIGN KEY (nid) REFERENCES networks(id) ON UPDATE RESTRICT ON DELETE CASCADE; diff --git a/persistence/sql/migrations/20220823111500000000_support_device_grants.up.sql b/persistence/sql/migrations/20220823111500000000_support_device_grants.up.sql new file mode 100644 index 00000000000..60f71119cc6 --- /dev/null +++ b/persistence/sql/migrations/20220823111500000000_support_device_grants.up.sql @@ -0,0 +1,41 @@ +CREATE TABLE IF NOT EXISTS hydra_oauth2_device_code +( + signature VARCHAR(255) NOT NULL PRIMARY KEY, + request_id VARCHAR(255) NOT NULL, + requested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + client_id VARCHAR(255) NOT NULL, + scope TEXT NOT NULL, + granted_scope TEXT NOT NULL, + form_data TEXT NOT NULL, + session_data TEXT NOT NULL, + subject VARCHAR(255) NOT NULL DEFAULT '', + active BOOL NOT NULL DEFAULT true, + requested_audience TEXT NULL DEFAULT '', + granted_audience TEXT NULL DEFAULT '', + challenge_id VARCHAR(40) NULL REFERENCES hydra_oauth2_flow (consent_challenge_id) ON DELETE CASCADE, + nid VARCHAR(36) NULL REFERENCES networks(id) ON DELETE CASCADE ON UPDATE RESTRICT +); +CREATE INDEX hydra_oauth2_device_code_request_id_idx ON hydra_oauth2_device_code (request_id, nid); +CREATE INDEX hydra_oauth2_device_code_client_id_idx ON hydra_oauth2_device_code (client_id, nid); +CREATE INDEX hydra_oauth2_device_code_challenge_id_idx ON hydra_oauth2_device_code (challenge_id, nid); + +CREATE TABLE IF NOT EXISTS hydra_oauth2_user_code +( + signature VARCHAR(255) NOT NULL PRIMARY KEY, + request_id VARCHAR(40) NOT NULL, + requested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + client_id VARCHAR(255) NOT NULL, + scope TEXT NOT NULL, + granted_scope TEXT NOT NULL, + form_data TEXT NOT NULL, + session_data TEXT NOT NULL, + subject VARCHAR(255) NOT NULL DEFAULT '', + active BOOL NOT NULL DEFAULT true, + requested_audience TEXT NULL DEFAULT '', + granted_audience TEXT NULL DEFAULT '', + challenge_id VARCHAR(40) NULL REFERENCES hydra_oauth2_flow (consent_challenge_id) ON DELETE CASCADE, + nid VARCHAR(36) NULL REFERENCES networks(id) ON DELETE CASCADE ON UPDATE RESTRICT +); +CREATE INDEX hydra_oauth2_user_code_request_id_idx ON hydra_oauth2_user_code (request_id, nid); +CREATE INDEX hydra_oauth2_user_code_client_id_idx ON hydra_oauth2_user_code (client_id, nid); +CREATE INDEX hydra_oauth2_user_code_challenge_id_idx ON hydra_oauth2_user_code (challenge_id, nid); \ No newline at end of file diff --git a/persistence/sql/migrations/20220824111500000000_support_device_grant_consent.cockroach.up.sql b/persistence/sql/migrations/20220824111500000000_support_device_grant_consent.cockroach.up.sql new file mode 100644 index 00000000000..d7d5ecba5b4 --- /dev/null +++ b/persistence/sql/migrations/20220824111500000000_support_device_grant_consent.cockroach.up.sql @@ -0,0 +1,19 @@ +CREATE TABLE hydra_oauth2_device_grant_request +( + challenge VARCHAR(40) NOT NULL PRIMARY KEY, + requested_scope TEXT NOT NULL, + verifier VARCHAR(40) NOT NULL UNIQUE, + client_id VARCHAR(255) NULL, + request_url TEXT NOT NULL, + requested_audience VARCHAR(255) NULL DEFAULT '', + csrf VARCHAR(40) NOT NULL, + device_code_signature VARCHAR(255) NULL, + accepted BOOL NOT NULL DEFAULT true, + accepted_at TIMESTAMP NULL, + nid UUID NULL +); +CREATE INDEX hydra_oauth2_device_grant_request_client_id_idx ON hydra_oauth2_device_grant_request (client_id, nid); +CREATE INDEX hydra_oauth2_device_grant_request_verifier_idx ON hydra_oauth2_device_grant_request (verifier, nid); +CREATE INDEX hydra_oauth2_device_grant_request_challenge_idx ON hydra_oauth2_device_grant_request (challenge, nid); +ALTER TABLE hydra_oauth2_device_grant_request ADD CONSTRAINT hydra_oauth2_device_grant_request_client_id_fk FOREIGN KEY (client_id, nid) REFERENCES hydra_client(id, nid) ON DELETE CASCADE; +ALTER TABLE hydra_oauth2_device_grant_request ADD CONSTRAINT hydra_oauth2_device_grant_request_nid_fk_idx FOREIGN KEY (nid) REFERENCES networks(id) ON UPDATE RESTRICT ON DELETE CASCADE; diff --git a/persistence/sql/migrations/20220824111500000000_support_device_grant_consent.down.sql b/persistence/sql/migrations/20220824111500000000_support_device_grant_consent.down.sql new file mode 100644 index 00000000000..fdd3e7b01a5 --- /dev/null +++ b/persistence/sql/migrations/20220824111500000000_support_device_grant_consent.down.sql @@ -0,0 +1,7 @@ +ALTER TABLE + hydra_oauth2_device_grant_request DROP FOREIGN KEY IF EXISTS hydra_oauth2_device_grant_request_client_id_fk; + +ALTER TABLE + hydra_oauth2_device_grant_request DROP FOREIGN KEY IF EXISTS hydra_oauth2_device_grant_request_nid_fk_idx; + +DROP TABLE IF EXISTS hydra_oauth2_device_grant_request; \ No newline at end of file diff --git a/persistence/sql/migrations/20220824111500000000_support_device_grant_consent.mysql.up.sql b/persistence/sql/migrations/20220824111500000000_support_device_grant_consent.mysql.up.sql new file mode 100644 index 00000000000..c137880c5b6 --- /dev/null +++ b/persistence/sql/migrations/20220824111500000000_support_device_grant_consent.mysql.up.sql @@ -0,0 +1,19 @@ +CREATE TABLE hydra_oauth2_device_grant_request +( + challenge VARCHAR(40) NOT NULL PRIMARY KEY, + requested_scope TEXT NOT NULL, + verifier VARCHAR(40) NOT NULL UNIQUE, + client_id VARCHAR(255) NULL, + request_url TEXT NOT NULL, + requested_audience VARCHAR(255) NULL DEFAULT '', + csrf VARCHAR(40) NOT NULL, + device_code_signature VARCHAR(255) NULL, + accepted BOOL NOT NULL DEFAULT true, + accepted_at TIMESTAMP NULL, + nid VARCHAR(36) NULL +); +CREATE INDEX hydra_oauth2_device_grant_request_client_id_idx ON hydra_oauth2_device_grant_request (client_id, nid); +CREATE INDEX hydra_oauth2_device_grant_request_verifier_idx ON hydra_oauth2_device_grant_request (verifier, nid); +CREATE INDEX hydra_oauth2_device_grant_request_challenge_idx ON hydra_oauth2_device_grant_request (challenge, nid); +ALTER TABLE hydra_oauth2_device_grant_request ADD CONSTRAINT hydra_oauth2_device_grant_request_client_id_fk FOREIGN KEY (client_id, nid) REFERENCES hydra_client(id, nid) ON DELETE CASCADE; +ALTER TABLE hydra_oauth2_device_grant_request ADD CONSTRAINT hydra_oauth2_device_grant_request_nid_fk_idx FOREIGN KEY (nid) REFERENCES networks(id) ON UPDATE RESTRICT ON DELETE CASCADE; diff --git a/persistence/sql/migrations/20220824111500000000_support_device_grant_consent.postgres.up.sql b/persistence/sql/migrations/20220824111500000000_support_device_grant_consent.postgres.up.sql new file mode 100644 index 00000000000..d7d5ecba5b4 --- /dev/null +++ b/persistence/sql/migrations/20220824111500000000_support_device_grant_consent.postgres.up.sql @@ -0,0 +1,19 @@ +CREATE TABLE hydra_oauth2_device_grant_request +( + challenge VARCHAR(40) NOT NULL PRIMARY KEY, + requested_scope TEXT NOT NULL, + verifier VARCHAR(40) NOT NULL UNIQUE, + client_id VARCHAR(255) NULL, + request_url TEXT NOT NULL, + requested_audience VARCHAR(255) NULL DEFAULT '', + csrf VARCHAR(40) NOT NULL, + device_code_signature VARCHAR(255) NULL, + accepted BOOL NOT NULL DEFAULT true, + accepted_at TIMESTAMP NULL, + nid UUID NULL +); +CREATE INDEX hydra_oauth2_device_grant_request_client_id_idx ON hydra_oauth2_device_grant_request (client_id, nid); +CREATE INDEX hydra_oauth2_device_grant_request_verifier_idx ON hydra_oauth2_device_grant_request (verifier, nid); +CREATE INDEX hydra_oauth2_device_grant_request_challenge_idx ON hydra_oauth2_device_grant_request (challenge, nid); +ALTER TABLE hydra_oauth2_device_grant_request ADD CONSTRAINT hydra_oauth2_device_grant_request_client_id_fk FOREIGN KEY (client_id, nid) REFERENCES hydra_client(id, nid) ON DELETE CASCADE; +ALTER TABLE hydra_oauth2_device_grant_request ADD CONSTRAINT hydra_oauth2_device_grant_request_nid_fk_idx FOREIGN KEY (nid) REFERENCES networks(id) ON UPDATE RESTRICT ON DELETE CASCADE; diff --git a/persistence/sql/migrations/20220824111500000000_support_device_grant_consent.up.sql b/persistence/sql/migrations/20220824111500000000_support_device_grant_consent.up.sql new file mode 100644 index 00000000000..e7293119520 --- /dev/null +++ b/persistence/sql/migrations/20220824111500000000_support_device_grant_consent.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE hydra_oauth2_device_grant_request +( + challenge VARCHAR(40) NOT NULL PRIMARY KEY, + requested_scope TEXT NOT NULL, + verifier VARCHAR(40) NOT NULL UNIQUE, + client_id VARCHAR(255) NULL, + request_url TEXT NOT NULL, + requested_audience VARCHAR(255) NULL DEFAULT '', + csrf VARCHAR(40) NOT NULL, + device_code_signature VARCHAR(255) NULL, + accepted BOOL NOT NULL DEFAULT true, + accepted_at TIMESTAMP NULL, + nid VARCHAR(36) NULL REFERENCES networks(id) ON DELETE CASCADE ON UPDATE RESTRICT +); +CREATE INDEX hydra_oauth2_device_grant_request_client_id_idx ON hydra_oauth2_device_grant_request (client_id); +CREATE INDEX hydra_oauth2_device_grant_request_verifier_idx ON hydra_oauth2_device_grant_request (verifier); +CREATE INDEX hydra_oauth2_device_grant_request_challenge_idx ON hydra_oauth2_device_grant_request (challenge); \ No newline at end of file diff --git a/persistence/sql/persister_consent.go b/persistence/sql/persister_consent.go index 5eb98af0d6c..3493ab306cb 100644 --- a/persistence/sql/persister_consent.go +++ b/persistence/sql/persister_consent.go @@ -217,6 +217,59 @@ func (p *Persister) GetConsentRequest(ctx context.Context, challenge string) (*f return f.GetConsentRequest(), nil } +func (p *Persister) CreateDeviceGrantRequest(ctx context.Context, req *flow.DeviceGrantRequest) error { + return errorsx.WithStack(p.CreateWithNetwork(ctx, req)) +} + +func (p *Persister) GetDeviceGrantRequestByVerifier(ctx context.Context, verifier string) (*flow.DeviceGrantRequest, error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.GetDeviceGrantRequestByVerifier") + defer span.End() + + var dgr flow.DeviceGrantRequest + return &dgr, sqlcon.HandleError(p.QueryWithNetwork(ctx).Where("verifier = ?", verifier).First(&dgr)) +} + +func (p *Persister) AcceptDeviceGrantRequest(ctx context.Context, challenge string, device_code_signature string, client_id string, requested_scopes fosite.Arguments, requested_aud fosite.Arguments) (*flow.DeviceGrantRequest, error) { + var dgr flow.DeviceGrantRequest + if err := p.transaction(ctx, func(ctx context.Context, c *pop.Connection) error { + if err := p.QueryWithNetwork(ctx).Where("challenge = ?", challenge).First(&dgr); err != nil { + return sqlcon.HandleError(err) + } + + dgr.Accepted = true + dgr.AcceptedAt = sqlxx.NullTime(time.Now()) + dgr.DeviceCodeSignature = sqlxx.NullString(device_code_signature) + dgr.ClientID = sqlxx.NullString(client_id) + dgr.RequestedScope = sqlxx.StringSlicePipeDelimiter(requested_scopes) + dgr.RequestedAudience = sqlxx.StringSlicePipeDelimiter(requested_aud) + + count, err := p.UpdateWithNetwork(ctx, &dgr) + if count != 1 { + return errorsx.WithStack(x.ErrNotFound) + } + return err + }); err != nil { + return nil, sqlcon.HandleError(err) + } + + return p.GetDeviceGrantRequestByVerifier(ctx, dgr.Verifier) +} + +func (p *Persister) VerifyAndInvalidateDeviceGrantRequest(ctx context.Context, verifier string) (*flow.DeviceGrantRequest, error) { + var dgr flow.DeviceGrantRequest + if err := p.transaction(ctx, func(ctx context.Context, c *pop.Connection) error { + if err := p.QueryWithNetwork(ctx).Where("verifier = ?", verifier).First(&dgr); err != nil { + return sqlcon.HandleError(err) + } + + return sqlcon.HandleError(p.QueryWithNetwork(ctx).Where("verifier = ?", verifier).Delete(&dgr)) + }); err != nil { + return nil, err + } + + return &dgr, nil +} + func (p *Persister) CreateLoginRequest(ctx context.Context, req *flow.LoginRequest) (*flow.Flow, error) { ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.CreateLoginRequest") defer span.End() diff --git a/persistence/sql/persister_nid_test.go b/persistence/sql/persister_nid_test.go index 2ca0f672be2..044666081cb 100644 --- a/persistence/sql/persister_nid_test.go +++ b/persistence/sql/persister_nid_test.go @@ -103,6 +103,37 @@ func (s *PersisterTestSuite) TestAcceptLogoutRequest() { } } +func (s *PersisterTestSuite) TestAcceptDeviceGrantRequest() { + t := s.T() + dgr := newDeviceGrantRequest() + + for k, r := range s.registries { + t.Run("dialect="+k, func(*testing.T) { + require.NoError(t, r.ConsentManager().CreateDeviceGrantRequest(s.t1, dgr)) + + expected, err := r.ConsentManager().GetDeviceGrantRequestByVerifier(s.t1, dgr.Verifier) + require.NoError(t, err) + require.Equal(t, false, expected.Accepted) + + client := &client.Client{ID: "client-id", Secret: "secret"} + require.NoError(t, r.Persister().CreateClient(s.t1, client)) + + dgrAccepted, err := r.ConsentManager().AcceptDeviceGrantRequest(s.t2, dgr.ID, "aaaa", "client-id", fosite.Arguments{"openid"}, fosite.Arguments{""}) + require.Error(t, err) + require.Equal(t, &flow.DeviceGrantRequest{}, dgrAccepted) + + actual, err := r.ConsentManager().GetDeviceGrantRequestByVerifier(s.t1, dgr.Verifier) + require.NoError(t, err) + require.Equal(t, expected, actual) + + dgrAccepted, err = r.ConsentManager().AcceptDeviceGrantRequest(s.t1, dgr.ID, "aaaa", "client-id", fosite.Arguments{"openid"}, fosite.Arguments{""}) + require.NoError(t, err) + require.Equal(t, dgrAccepted, actual) + require.Equal(t, true, actual.Accepted) + }) + } +} + func (s *PersisterTestSuite) TestAddKeyGetKeyDeleteKey() { t := s.T() key := newKey("test-ks", "test") @@ -2071,6 +2102,32 @@ func (s *PersisterTestSuite) TestVerifyAndInvalidateLogoutRequest() { } } +func (s *PersisterTestSuite) TestVerifyAndInvalidateDeviceGrantRequest() { + t := s.T() + for k, r := range s.registries { + t.Run(k, func(t *testing.T) { + dgr := newDeviceGrantRequest() + + actual := &flow.DeviceGrantRequest{} + + require.NoError(t, r.ConsentManager().CreateDeviceGrantRequest(s.t1, dgr)) + + expected, err := r.ConsentManager().GetDeviceGrantRequestByVerifier(s.t1, dgr.Verifier) + require.NoError(t, err) + + dgrInvalidated, err := r.ConsentManager().VerifyAndInvalidateDeviceGrantRequest(s.t2, dgr.Verifier) + require.Error(t, err) + require.Nil(t, dgrInvalidated) + require.NoError(t, r.Persister().Connection(context.Background()).Find(actual, dgr.ID)) + require.Equal(t, expected, actual) + + _, err = r.ConsentManager().VerifyAndInvalidateDeviceGrantRequest(s.t1, dgr.Verifier) + require.NoError(t, err) + require.Error(t, x.ErrNotFound, r.Persister().Connection(context.Background()).Find(actual, dgr.ID)) + }) + } +} + func (s *PersisterTestSuite) TestWithFallbackNetworkID() { t := s.T() for k, r := range s.registries { @@ -2133,6 +2190,13 @@ func newGrant(keySet string, keyID string) trust.Grant { } } +func newDeviceGrantRequest() *flow.DeviceGrantRequest { + return &flow.DeviceGrantRequest{ + ID: uuid.Must(uuid.NewV4()).String(), + Verifier: uuid.Must(uuid.NewV4()).String(), + } +} + func newLogoutRequest() *flow.LogoutRequest { return &flow.LogoutRequest{ ID: uuid.Must(uuid.NewV4()).String(), diff --git a/persistence/sql/persister_oauth2.go b/persistence/sql/persister_oauth2.go index c49c9c7f823..abdcf2e7234 100644 --- a/persistence/sql/persister_oauth2.go +++ b/persistence/sql/persister_oauth2.go @@ -56,11 +56,13 @@ type ( ) const ( - sqlTableOpenID tableName = "oidc" - sqlTableAccess tableName = "access" - sqlTableRefresh tableName = "refresh" - sqlTableCode tableName = "code" - sqlTablePKCE tableName = "pkce" + sqlTableOpenID tableName = "oidc" + sqlTableAccess tableName = "access" + sqlTableRefresh tableName = "refresh" + sqlTableCode tableName = "code" + sqlTablePKCE tableName = "pkce" + sqlTableDeviceCode tableName = "device_code" + sqlTableUserCode tableName = "user_code" ) func (r OAuth2RequestSQL) TableName() string { @@ -228,6 +230,28 @@ func (p *Persister) createSession(ctx context.Context, signature string, request return nil } +func (p *Persister) updateSessionBySignature(ctx context.Context, signature string, requester fosite.Requester, table tableName) (err error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.updateSession") + defer otelx.End(span, &err) + + req, err := p.sqlSchemaFromRequest(ctx, signature, requester, table) + if err != nil { + return err + } + + if count, err := p.UpdateWithNetwork(ctx, req); count != 1 { + return errorsx.WithStack(fosite.ErrNotFound) + } else if err := sqlcon.HandleError(err); err != nil { + if errors.Is(err, sqlcon.ErrConcurrentUpdate) { + return errors.Wrap(fosite.ErrSerializationFailure, err.Error()) + } else if strings.Contains(err.Error(), "Error 1213") { // InnoDB Deadlock? + return errors.Wrap(fosite.ErrSerializationFailure, err.Error()) + } + return err + } + return nil +} + func (p *Persister) findSessionBySignature(ctx context.Context, signature string, session fosite.Session, table tableName) (fosite.Requester, error) { r := OAuth2RequestSQL{Table: table} err := p.QueryWithNetwork(ctx).Where("signature = ?", signature).First(&r) @@ -244,8 +268,21 @@ func (p *Persister) findSessionBySignature(ctx context.Context, signature string } if table == sqlTableCode { return fr, errorsx.WithStack(fosite.ErrInvalidatedAuthorizeCode) + } else if table == sqlTableDeviceCode { + return fr, errorsx.WithStack(fosite.ErrInvalidatedDeviceCode) + } else if table == sqlTableUserCode { + return fr, errorsx.WithStack(fosite.ErrInvalidatedUserCode) } return fr, errorsx.WithStack(fosite.ErrInactiveToken) + } else if !r.ConsentChallenge.Valid { + fr, err := r.toRequest(ctx, session, p) + if err != nil { + return nil, err + } + + if table == sqlTableDeviceCode { + return fr, errorsx.WithStack(fosite.ErrAuthorizationPending) + } } return r.toRequest(ctx, session, p) @@ -541,8 +578,71 @@ func (p *Persister) FlushInactiveRefreshTokens(ctx context.Context, notAfter tim func (p *Persister) DeleteAccessTokens(ctx context.Context, clientID string) (err error) { ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.DeleteAccessTokens") defer otelx.End(span, &err) + /* #nosec G201 table is static */ return sqlcon.HandleError( p.QueryWithNetwork(ctx).Where("client_id=?", clientID).Delete(&OAuth2RequestSQL{Table: sqlTableAccess}), ) } + +func (p *Persister) CreateDeviceCodeSession(ctx context.Context, signature string, requester fosite.Requester) (err error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.CreateDeviceCodeSession") + defer otelx.End(span, &err) + return p.createSession(ctx, signature, requester, sqlTableDeviceCode) +} + +func (p *Persister) UpdateDeviceCodeSession(ctx context.Context, signature string, requester fosite.Requester) (err error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.UpdateDeviceCodeSession") + defer otelx.End(span, &err) + return p.updateSessionBySignature(ctx, signature, requester, sqlTableDeviceCode) +} + +func (p *Persister) GetDeviceCodeSession(ctx context.Context, signature string, session fosite.Session) (_ fosite.Requester, err error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.GetDeviceCodeSession") + defer otelx.End(span, &err) + return p.findSessionBySignature(ctx, signature, session, sqlTableDeviceCode) +} + +func (p *Persister) InvalidateDeviceCodeSession(ctx context.Context, signature string) (err error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.InvalidateDeviceCodeSession") + defer otelx.End(span, &err) + + /* #nosec G201 table is static */ + return sqlcon.HandleError( + p.Connection(ctx). + RawQuery( + fmt.Sprintf("UPDATE %s SET active=false WHERE signature=? AND nid = ?", OAuth2RequestSQL{Table: sqlTableDeviceCode}.TableName()), + signature, + p.NetworkID(ctx), + ). + Exec(), + ) +} + +func (p *Persister) CreateUserCodeSession(ctx context.Context, signature string, requester fosite.Requester) (err error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.CreateUserCodeSession") + defer otelx.End(span, &err) + return p.createSession(ctx, signature, requester, sqlTableUserCode) +} + +func (p *Persister) GetUserCodeSession(ctx context.Context, signature string, session fosite.Session) (_ fosite.Requester, err error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.GetUserCodeSession") + defer otelx.End(span, &err) + return p.findSessionBySignature(ctx, signature, session, sqlTableUserCode) +} + +func (p *Persister) InvalidateUserCodeSession(ctx context.Context, signature string) (err error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.InvalidateUserCodeSession") + defer otelx.End(span, &err) + + /* #nosec G201 table is static */ + return sqlcon.HandleError( + p.Connection(ctx). + RawQuery( + fmt.Sprintf("UPDATE %s SET active=false WHERE signature=? AND nid = ?", OAuth2RequestSQL{Table: sqlTableUserCode}.TableName()), + signature, + p.NetworkID(ctx), + ). + Exec(), + ) +} diff --git a/spec/api.json b/spec/api.json index da714ba54dc..9298dfbdaf5 100644 --- a/spec/api.json +++ b/spec/api.json @@ -128,6 +128,13 @@ "title": "StringSliceJSONFormat represents []string{} which is encoded to/from JSON for SQL storage.", "type": "array" }, + "StringSlicePipeDelimiter": { + "items": { + "type": "string" + }, + "title": "StringSlicePipeDelimiter de/encodes the string slice to/from a SQL string.", + "type": "array" + }, "Time": { "format": "date-time", "type": "string" @@ -286,6 +293,77 @@ "title": "Verifiable Credentials Metadata (Draft 00)", "type": "object" }, + "deviceAuthorization": { + "description": "# Ory's OAuth 2.0 Device Authorization API", + "properties": { + "device_code": { + "description": "The device verification code.", + "example": "ory_dc_smldfksmdfkl.mslkmlkmlk", + "type": "string" + }, + "expires_in": { + "description": "The lifetime in seconds of the \"device_code\" and \"user_code\".", + "example": 16830, + "format": "int64", + "type": "integer" + }, + "interval": { + "description": "The minimum amount of time in seconds that the client\nSHOULD wait between polling requests to the token endpoint. If no\nvalue is provided, clients MUST use 5 as the default.", + "example": 5, + "format": "int64", + "type": "integer" + }, + "user_code": { + "description": "The end-user verification code.", + "example": "AAAAAA", + "type": "string" + }, + "verification_uri": { + "description": "The end-user verification URI on the authorization\nserver. The URI should be short and easy to remember as end users\nwill be asked to manually type it into their user agent.", + "example": "https://auth.ory.sh/tv", + "type": "string" + }, + "verification_uri_complete": { + "description": "A verification URI that includes the \"user_code\" (or\nother information with the same function as the \"user_code\"),\nwhich is designed for non-textual transmission.", + "example": "https://auth.ory.sh/tv?user_code=AAAAAA", + "type": "string" + } + }, + "title": "OAuth2 Device Flow", + "type": "object" + }, + "deviceGrantRequest": { + "properties": { + "challenge": { + "description": "ID is the identifier (\"device challenge\") of the device grant request. It is used to\nidentify the session.", + "type": "string" + }, + "client": { + "$ref": "#/components/schemas/oAuth2Client" + }, + "handled_at": { + "$ref": "#/components/schemas/nullTime" + }, + "request_url": { + "description": "RequestURL is the original Device Grant URL requested.", + "type": "string" + }, + "requested_access_token_audience": { + "$ref": "#/components/schemas/StringSlicePipeDelimiter" + }, + "requested_scope": { + "$ref": "#/components/schemas/StringSlicePipeDelimiter" + } + }, + "required": [ + "challenge", + "requested_scope", + "requested_access_token_audience", + "client" + ], + "title": "Contains information on an ongoing device grant request.", + "type": "object" + }, "errorOAuth2": { "description": "Error", "properties": { @@ -1137,6 +1215,10 @@ }, "type": "array" }, + "device_authorization_endpoint": { + "description": "URL of the authorization server's device authorization endpoint", + "type": "string" + }, "end_session_endpoint": { "description": "OpenID Connect End-Session Endpoint\n\nURL at the OP to which an RP can perform a redirect to request that the End-User be logged out at the OP.", "type": "string" @@ -1655,6 +1737,15 @@ "title": "VerifiableCredentialResponse contains the verifiable credential.", "type": "object" }, + "verifyUserCodeRequest": { + "description": "Contains information on an device verification", + "properties": { + "user_code": { + "type": "string" + } + }, + "type": "object" + }, "version": { "properties": { "version": { @@ -2581,6 +2672,58 @@ ] } }, + "/admin/oauth2/auth/requests/device/verify": { + "put": { + "description": "Verifies a device grant request", + "operationId": "verifyUserCodeRequest", + "parameters": [ + { + "in": "query", + "name": "device_challenge", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/verifyUserCodeRequest" + } + } + }, + "x-originalParamName": "Body" + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/oAuth2RedirectTo" + } + } + }, + "description": "oAuth2RedirectTo" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/errorOAuth2" + } + } + }, + "description": "errorOAuth2" + } + }, + "summary": "Verifies a device grant request", + "tags": [ + "oAuth2" + ] + } + }, "/admin/oauth2/auth/requests/login": { "get": { "description": "When an authorization code, hybrid, or implicit OAuth 2.0 Flow is initiated, Ory asks the login provider\nto authenticate the subject and then tell the Ory OAuth2 Service about it.\n\nPer default, the login provider is Ory itself. You may use a different login provider which needs to be a web-app\nyou write and host, and it must be able to authenticate (\"show the subject a login screen\")\na subject (in OAuth2 the proper name for subject is \"resource owner\").\n\nThe authentication challenge is appended to the login provider URL to which the subject's user-agent (browser) is redirected to. The login\nprovider uses that challenge to fetch information on the OAuth2 request and then accept or reject the requested authentication process.", @@ -3462,6 +3605,38 @@ ] } }, + "/oauth2/device/auth": { + "get": { + "description": "This endpoint is not documented here because you should never use your own implementation to perform OAuth2 flows.\nOAuth2 is a very popular protocol and a library for your programming language will exists.\n\nTo learn more about this flow please refer to the specification: https://tools.ietf.org/html/rfc8628", + "operationId": "performOAuth2DeviceFlow", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/deviceAuthorization" + } + } + }, + "description": "deviceAuthorization" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/errorOAuth2" + } + } + }, + "description": "errorOAuth2" + } + }, + "summary": "The OAuth 2.0 Device Authorize Endpoint", + "tags": [ + "v0alpha2" + ] + } + }, "/oauth2/register": { "post": { "description": "This endpoint behaves like the administrative counterpart (`createOAuth2Client`) but is capable of facing the\npublic internet directly and can be used in self-service. It implements the OpenID Connect\nDynamic Client Registration Protocol. This feature needs to be enabled in the configuration. This endpoint\nis disabled by default. It can be enabled by an administrator.\n\nPlease note that using this endpoint you are not able to choose the `client_secret` nor the `client_id` as those\nvalues will be server generated when specifying `token_endpoint_auth_method` as `client_secret_basic` or\n`client_secret_post`.\n\nThe `client_secret` will be returned in the response and you will not be able to retrieve it later on.\nWrite the secret down and keep it somewhere safe.", diff --git a/spec/config.json b/spec/config.json index 9899db71df0..d766c111e32 100644 --- a/spec/config.json +++ b/spec/config.json @@ -474,6 +474,11 @@ "title": "CSRF Cookie Name", "default": "ory_hydra_consent_csrf" }, + "consent_device_verify": { + "type": "string", + "title": "CSRF Cookie Name", + "default": "ory_hydra_device_verify_csrf" + }, "session": { "type": "string", "title": "Session Cookie Name", @@ -614,6 +619,14 @@ "https://my-service.com/oauth2/auth" ] }, + "device_authorization_url": { + "type": "string", + "description": "Overwrites the OAuth2 Device Auth URL", + "format": "uri-reference", + "examples": [ + "https://my-service.com/oauth2/device/auth" + ] + }, "client_registration_url": { "description": "Sets the OpenID Connect Dynamic Client Registration Endpoint", "type": "string", @@ -803,6 +816,15 @@ "/ui/logout" ] }, + "device": { + "type": "string", + "description": "Sets the device auth endpoint. Defaults to an internal fallback URL showing an error.", + "format": "uri-reference", + "examples": [ + "https://my-logout.app/device", + "/ui/device" + ] + }, "error": { "type": "string", "description": "Sets the error endpoint. The error ui will be shown when an OAuth2 error occurs that which can not be sent back to the client. Defaults to an internal fallback URL showing an error.", @@ -821,6 +843,15 @@ "/ui" ] }, + "post_device_done": { + "type": "string", + "description": "When a user agent requests to device auth flow, it will be redirected to this url after a sucessfull login per default.", + "format": "uri-reference", + "examples": [ + "https://my-example.app/device-successful", + "/ui" + ] + }, "identity_provider": { "type": "object", "additionalProperties": false, @@ -947,6 +978,15 @@ "$ref": "#/definitions/duration" } ] + }, + "device_user_code": { + "description": "Configures how long device & user codes are valid.", + "default": "10m", + "allOf": [ + { + "$ref": "#/definitions/duration" + } + ] } } }, @@ -1109,6 +1149,22 @@ } ] }, + "device_authorization": { + "type": "object", + "additionalProperties": false, + "properties": { + "token_polling_interval": { + "allOf": [ + { + "$ref": "#/definitions/duration" + } + ], + "default": "5s", + "description": "configure how often a non-interactive device should poll the device token endpoint", + "examples": ["5s", "15s", "1m"] + } + } + }, "token_hook": { "description": "Sets the token hook endpoint for all grant types. If set it will be called while providing token to customize claims.", "examples": ["https://my-example.app/token-hook"], diff --git a/spec/swagger.json b/spec/swagger.json index 8ff1115b755..b2146dadd37 100755 --- a/spec/swagger.json +++ b/spec/swagger.json @@ -891,6 +891,55 @@ } } }, + "/admin/oauth2/auth/requests/device/verify": { + "put": { + "description": "Verifies a device grant request", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "schemes": [ + "http", + "https" + ], + "tags": [ + "oAuth2" + ], + "summary": "Verifies a device grant request", + "operationId": "verifyUserCodeRequest", + "parameters": [ + { + "type": "string", + "name": "device_challenge", + "in": "query", + "required": true + }, + { + "name": "Body", + "in": "body", + "schema": { + "$ref": "#/definitions/verifyUserCodeRequest" + } + } + ], + "responses": { + "200": { + "description": "oAuth2RedirectTo", + "schema": { + "$ref": "#/definitions/oAuth2RedirectTo" + } + }, + "default": { + "description": "errorOAuth2", + "schema": { + "$ref": "#/definitions/errorOAuth2" + } + } + } + } + }, "/admin/oauth2/auth/requests/login": { "get": { "description": "When an authorization code, hybrid, or implicit OAuth 2.0 Flow is initiated, Ory asks the login provider\nto authenticate the subject and then tell the Ory OAuth2 Service about it.\n\nPer default, the login provider is Ory itself. You may use a different login provider which needs to be a web-app\nyou write and host, and it must be able to authenticate (\"show the subject a login screen\")\na subject (in OAuth2 the proper name for subject is \"resource owner\").\n\nThe authentication challenge is appended to the login provider URL to which the subject's user-agent (browser) is redirected to. The login\nprovider uses that challenge to fetch information on the OAuth2 request and then accept or reject the requested authentication process.", @@ -1713,6 +1762,37 @@ } } }, + "/oauth2/device/auth": { + "get": { + "description": "This endpoint is not documented here because you should never use your own implementation to perform OAuth2 flows.\nOAuth2 is a very popular protocol and a library for your programming language will exists.\n\nTo learn more about this flow please refer to the specification: https://tools.ietf.org/html/rfc8628", + "consumes": [ + "application/x-www-form-urlencoded" + ], + "schemes": [ + "http", + "https" + ], + "tags": [ + "v0alpha2" + ], + "summary": "The OAuth 2.0 Device Authorize Endpoint", + "operationId": "performOAuth2DeviceFlow", + "responses": { + "200": { + "description": "deviceAuthorization", + "schema": { + "$ref": "#/definitions/deviceAuthorization" + } + }, + "default": { + "description": "errorOAuth2", + "schema": { + "$ref": "#/definitions/errorOAuth2" + } + } + } + } + }, "/oauth2/register": { "post": { "description": "This endpoint behaves like the administrative counterpart (`createOAuth2Client`) but is capable of facing the\npublic internet directly and can be used in self-service. It implements the OpenID Connect\nDynamic Client Registration Protocol. This feature needs to be enabled in the configuration. This endpoint\nis disabled by default. It can be enabled by an administrator.\n\nPlease note that using this endpoint you are not able to choose the `client_secret` nor the `client_id` as those\nvalues will be server generated when specifying `token_endpoint_auth_method` as `client_secret_basic` or\n`client_secret_post`.\n\nThe `client_secret` will be returned in the response and you will not be able to retrieve it later on.\nWrite the secret down and keep it somewhere safe.", @@ -2157,6 +2237,13 @@ "type": "string" } }, + "StringSlicePipeDelimiter": { + "type": "array", + "title": "StringSlicePipeDelimiter de/encodes the string slice to/from a SQL string.", + "items": { + "type": "string" + } + }, "VerifiableCredentialProof": { "type": "object", "title": "VerifiableCredentialProof contains the proof of a verifiable credential.", @@ -2311,6 +2398,77 @@ } } }, + "deviceAuthorization": { + "description": "# Ory's OAuth 2.0 Device Authorization API", + "type": "object", + "title": "OAuth2 Device Flow", + "properties": { + "device_code": { + "description": "The device verification code.", + "type": "string", + "example": "ory_dc_smldfksmdfkl.mslkmlkmlk" + }, + "expires_in": { + "description": "The lifetime in seconds of the \"device_code\" and \"user_code\".", + "type": "integer", + "format": "int64", + "example": 16830 + }, + "interval": { + "description": "The minimum amount of time in seconds that the client\nSHOULD wait between polling requests to the token endpoint. If no\nvalue is provided, clients MUST use 5 as the default.", + "type": "integer", + "format": "int64", + "example": 5 + }, + "user_code": { + "description": "The end-user verification code.", + "type": "string", + "example": "AAAAAA" + }, + "verification_uri": { + "description": "The end-user verification URI on the authorization\nserver. The URI should be short and easy to remember as end users\nwill be asked to manually type it into their user agent.", + "type": "string", + "example": "https://auth.ory.sh/tv" + }, + "verification_uri_complete": { + "description": "A verification URI that includes the \"user_code\" (or\nother information with the same function as the \"user_code\"),\nwhich is designed for non-textual transmission.", + "type": "string", + "example": "https://auth.ory.sh/tv?user_code=AAAAAA" + } + } + }, + "deviceGrantRequest": { + "type": "object", + "title": "Contains information on an ongoing device grant request.", + "required": [ + "challenge", + "requested_scope", + "requested_access_token_audience", + "client" + ], + "properties": { + "challenge": { + "description": "ID is the identifier (\"device challenge\") of the device grant request. It is used to\nidentify the session.", + "type": "string" + }, + "client": { + "$ref": "#/definitions/oAuth2Client" + }, + "handled_at": { + "$ref": "#/definitions/nullTime" + }, + "request_url": { + "description": "RequestURL is the original Device Grant URL requested.", + "type": "string" + }, + "requested_access_token_audience": { + "$ref": "#/definitions/StringSlicePipeDelimiter" + }, + "requested_scope": { + "$ref": "#/definitions/StringSlicePipeDelimiter" + } + } + }, "errorOAuth2": { "description": "Error", "type": "object", @@ -3143,6 +3301,10 @@ "$ref": "#/definitions/credentialSupportedDraft00" } }, + "device_authorization_endpoint": { + "description": "URL of the authorization server's device authorization endpoint", + "type": "string" + }, "end_session_endpoint": { "description": "OpenID Connect End-Session Endpoint\n\nURL at the OP to which an RP can perform a redirect to request that the End-User be logged out at the OP.", "type": "string" @@ -3648,6 +3810,15 @@ } } }, + "verifyUserCodeRequest": { + "description": "Contains information on an device verification", + "type": "object", + "properties": { + "user_code": { + "type": "string" + } + } + }, "version": { "type": "object", "properties": { diff --git a/x/clean_sql.go b/x/clean_sql.go index a02a9a054ce..31040e6a249 100644 --- a/x/clean_sql.go +++ b/x/clean_sql.go @@ -16,6 +16,9 @@ func DeleteHydraRows(t *testing.T, c *pop.Connection) { "hydra_oauth2_code", "hydra_oauth2_oidc", "hydra_oauth2_pkce", + "hydra_oauth2_device_code", + "hydra_oauth2_user_code", + "hydra_oauth2_device_grant_request", "hydra_oauth2_flow", "hydra_oauth2_authentication_session", "hydra_oauth2_obfuscated_authentication_session", @@ -37,8 +40,11 @@ func CleanSQLPop(t *testing.T, c *pop.Connection) { "hydra_oauth2_access", "hydra_oauth2_refresh", "hydra_oauth2_code", + "hydra_oauth2_device_code", + "hydra_oauth2_user_code", "hydra_oauth2_oidc", "hydra_oauth2_pkce", + "hydra_oauth2_device_grant_request", "hydra_oauth2_flow", "hydra_oauth2_authentication_session", "hydra_oauth2_obfuscated_authentication_session", diff --git a/x/fosite_storer.go b/x/fosite_storer.go index 23654c519b9..56778dc6bb7 100644 --- a/x/fosite_storer.go +++ b/x/fosite_storer.go @@ -12,15 +12,18 @@ import ( "github.com/ory/fosite/handler/openid" "github.com/ory/fosite/handler/pkce" "github.com/ory/fosite/handler/rfc7523" + "github.com/ory/fosite/handler/rfc8628" "github.com/ory/fosite/handler/verifiable" ) type FositeStorer interface { fosite.Storage oauth2.CoreStorage + oauth2.AuthorizeCodeStorage openid.OpenIDConnectRequestStorage pkce.PKCERequestStorage rfc7523.RFC7523KeyStorage + rfc8628.RFC8628CodeStorage verifiable.NonceManager oauth2.ResourceOwnerPasswordCredentialsGrantStorage