package oidc import ( "context" "errors" "net/http" "time" "github.com/ory/fosite" "github.com/ory/fosite/handler/rfc8628" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" "gorm.io/gorm" ) type deviceService struct { provider fosite.OAuth2Provider store *Store userCodeStrategy rfc8628.UserCodeStrategy authorizationService *authorizationService claimsService *ClaimsService auditLog AuditLogger db *gorm.DB } func newDeviceService( provider fosite.OAuth2Provider, store *Store, userCodeStrategy rfc8628.UserCodeStrategy, authorizationService *authorizationService, claimsService *ClaimsService, auditLog AuditLogger, db *gorm.DB, ) *deviceService { return &deviceService{ provider: provider, store: store, userCodeStrategy: userCodeStrategy, authorizationService: authorizationService, claimsService: claimsService, auditLog: auditLog, db: db, } } func (s *deviceService) createDeviceAuthorization(ctx context.Context, req *http.Request) (*dto.OidcDeviceAuthorizationResponseDto, fosite.Requester, error) { request, err := s.provider.NewDeviceRequest(ctx, req) if err != nil { return nil, request, err } // Validate the requested scopes and resolve the resource indicator to an audience and the subset of requested scopes that may be granted client := request.GetClient().(Client) resource, err := request.GetResource() if err != nil { return nil, request, err } audience, grantedScopes, _, err := s.authorizationService.resolveGrant(ctx, client.GetID(), resource, request.GetRequestedScopes()) if err != nil { if resource != "" && errors.Is(err, fosite.ErrAccessDenied) { return nil, request, fosite.ErrInvalidTarget.WithHintf("The requested resource '%s' is invalid, missing, unknown, or malformed.", resource) } return nil, request, err } grantResourceIndicator(request, audience, grantedScopes) session := NewEmptySession() response, err := s.provider.NewDeviceResponse(ctx, request, session) if err != nil { return nil, request, err } return &dto.OidcDeviceAuthorizationResponseDto{ DeviceCode: response.GetDeviceCode(), UserCode: response.GetUserCode(), VerificationURI: response.GetVerificationURI(), VerificationURIComplete: response.GetVerificationURIComplete(), ExpiresIn: int(response.GetExpiresIn()), Interval: response.GetInterval(), }, request, nil } func (s *deviceService) acceptDeviceCode(ctx context.Context, userCode, userID, authenticationMethod string, authenticationTime time.Time, reauthenticationToken string, meta requestMeta) error { request, userCodeSignature, err := s.deviceRequestFromUserCode(ctx, userCode) if err != nil { return err } // A user code may be approved only once. Rejecting an already-approved code prevents a second // logged-in user from rebinding a pending device authorization to themselves before the device // polls for its token. if request.GetUserCodeState() != fosite.UserCodeUnused { return &common.OidcInvalidDeviceCodeError{} } client := request.GetClient().(Client) var user model.User if err = s.db.WithContext(ctx).Preload("UserGroups").First(&user, "id = ?", userID).Error; err != nil { return err } if !IsUserGroupAllowedToAuthorize(user, client.OidcClient) { return fosite.ErrAccessDenied.WithHint("You are not allowed to access this service.") } resource, err := request.GetResource() if err != nil { return err } audience, grantedScopes, consentKeys, err := s.authorizationService.resolveGrant(ctx, client.GetID(), resource, request.GetRequestedScopes()) if err != nil { return err } grantResourceIndicator(request, audience, grantedScopes) return withTx(ctx, s.db, func(ctx context.Context) error { if client.RequiresReauthentication { if reauthenticationToken == "" || s.authorizationService == nil || s.authorizationService.reauth == nil { return &common.ReauthenticationRequiredError{} } reauthenticatedAt, err := s.authorizationService.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), reauthenticationToken, userID) if err != nil { return err } authenticationTime = reauthenticatedAt } if authenticationTime.IsZero() { authenticationTime = time.Now().UTC() } session := NewAuthenticatedSession(userID, authenticationMethod, authenticationTime, request.GetRequestedAt()) if err = s.claimsService.applyIDTokenClaims(ctx, session, request.GetGrantedScopes()); err != nil { return err } request.SetSession(session) hasAlreadyAuthorizedClient, err := s.authorizationService.consent(ctx, userID, client.GetID(), consentKeys) if err != nil { return err } event := model.AuditLogEventDeviceCodeAuthorization if !hasAlreadyAuthorizedClient { event = model.AuditLogEventNewDeviceCodeAuthorization } s.auditLog.Create(ctx, event, meta.IPAddress, meta.UserAgent, userID, model.AuditLogData{"clientName": client.Name}, dbFromContext(ctx, s.db)) deviceCodeSignature, err := s.store.AcceptDeviceCodeSessionByUserCodeSignature(ctx, userCodeSignature, request) if err != nil { return err } if request.GetGrantedScopes().Has("openid") { if err := s.store.CreateOpenIDConnectSession(ctx, deviceCodeSignature, request); err != nil { return err } } return nil }) } func (s *deviceService) getDeviceCodeInfo(ctx context.Context, userCode, userID string) (*dto.DeviceCodeInfoDto, error) { request, _, err := s.deviceRequestFromUserCode(ctx, userCode) if err != nil { return nil, err } client := request.GetClient().(Client) resource, err := request.GetResource() if err != nil { return nil, err } authorizationRequired := true if userID != "" { _, _, consentKeys, err := s.authorizationService.resolveGrant(ctx, client.GetID(), resource, request.GetRequestedScopes()) if err != nil { return nil, err } hasAuthorizedClient, err := s.authorizationService.hasAuthorizedClient(ctx, client.GetID(), userID, consentKeys) if err != nil { return nil, err } // The device flow has no per-request prompt parameter, so consent depends only on prior authorization and the client's skip-consent setting authorizationRequired = consentRequired(hasAuthorizedClient, client.SkipConsent, nil) } scope := request.GetRequestedScopes() if scope == nil { scope = []string{} } // Resolve friendly names for the requested custom-API permissions so the device consent screen matches the browser flow scopeInfo, err := s.authorizationService.resolveScopeInfoForRequest(ctx, resource, scope) if err != nil { return nil, err } // Always serialize a possibly empty array rather than null if scopeInfo == nil { scopeInfo = []dto.ScopeInfoDto{} } return &dto.DeviceCodeInfoDto{ Client: dto.OidcClientMetaDataDto{ ID: client.ID, Name: client.Name, HasLogo: client.HasLogo(), HasDarkLogo: client.HasDarkLogo(), LaunchURL: client.LaunchURL, RequiresReauthentication: client.RequiresReauthentication, }, Scope: scope, ScopeInfo: scopeInfo, AuthorizationRequired: authorizationRequired, ReauthenticationRequired: client.RequiresReauthentication, }, nil } func (s *deviceService) deviceRequestFromUserCode(ctx context.Context, userCode string) (fosite.DeviceRequester, string, error) { userCodeSignature, err := s.userCodeStrategy.UserCodeSignature(ctx, userCode) if err != nil { return nil, "", err } request, err := s.store.GetDeviceCodeSessionByUserCodeSignature(ctx, userCodeSignature) if errors.Is(err, fosite.ErrNotFound) { return nil, "", &common.OidcInvalidDeviceCodeError{} } if err != nil { return nil, "", err } if err = s.userCodeStrategy.ValidateUserCode(ctx, request, userCode); err != nil { if errors.Is(err, fosite.ErrDeviceExpiredToken) { return nil, "", &common.OidcDeviceCodeExpiredError{} } return nil, "", err } return request, userCodeSignature, nil }