Files
pocket-id-pocket-id/backend/internal/oidc/device_service.go
2026-07-08 17:48:32 -07:00

244 lines
8.1 KiB
Go

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
}