mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-17 02:33:02 +03:00
feat: device authorization endpoint (#270)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -296,3 +296,51 @@ func (e *UserDisabledError) Error() string {
|
|||||||
func (e *UserDisabledError) HttpStatusCode() int {
|
func (e *UserDisabledError) HttpStatusCode() int {
|
||||||
return http.StatusForbidden
|
return http.StatusForbidden
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ValidationError struct {
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ValidationError) Error() string {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ValidationError) HttpStatusCode() int {
|
||||||
|
return http.StatusBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcDeviceCodeExpiredError struct{}
|
||||||
|
|
||||||
|
func (e *OidcDeviceCodeExpiredError) Error() string {
|
||||||
|
return "device code has expired"
|
||||||
|
}
|
||||||
|
func (e *OidcDeviceCodeExpiredError) HttpStatusCode() int {
|
||||||
|
return http.StatusBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcInvalidDeviceCodeError struct{}
|
||||||
|
|
||||||
|
func (e *OidcInvalidDeviceCodeError) Error() string {
|
||||||
|
return "invalid device code"
|
||||||
|
}
|
||||||
|
func (e *OidcInvalidDeviceCodeError) HttpStatusCode() int {
|
||||||
|
return http.StatusBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcSlowDownError struct{}
|
||||||
|
|
||||||
|
func (e *OidcSlowDownError) Error() string {
|
||||||
|
return "polling too frequently"
|
||||||
|
}
|
||||||
|
func (e *OidcSlowDownError) HttpStatusCode() int {
|
||||||
|
return http.StatusTooManyRequests
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcAuthorizationPendingError struct{}
|
||||||
|
|
||||||
|
func (e *OidcAuthorizationPendingError) Error() string {
|
||||||
|
return "authorization is still pending"
|
||||||
|
}
|
||||||
|
func (e *OidcAuthorizationPendingError) HttpStatusCode() int {
|
||||||
|
return http.StatusBadRequest
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewOidcController creates a new controller for OIDC related endpoints
|
// NewOidcController creates a new controller for OIDC related endpoints
|
||||||
@@ -45,6 +47,10 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
|||||||
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
|
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
|
||||||
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
|
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
|
||||||
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
|
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
|
||||||
|
|
||||||
|
group.POST("/oidc/device/authorize", oc.deviceAuthorizationHandler)
|
||||||
|
group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler)
|
||||||
|
group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcController struct {
|
type OidcController struct {
|
||||||
@@ -144,26 +150,28 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
clientID := input.ClientID
|
|
||||||
clientSecret := input.ClientSecret
|
|
||||||
|
|
||||||
// Client id and secret can also be passed over the Authorization header
|
// Client id and secret can also be passed over the Authorization header
|
||||||
if clientID == "" && clientSecret == "" {
|
if input.ClientID == "" && input.ClientSecret == "" {
|
||||||
clientID, clientSecret, _ = c.Request.BasicAuth()
|
input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
|
||||||
}
|
}
|
||||||
|
|
||||||
idToken, accessToken, refreshToken, expiresIn, err := oc.oidcService.CreateTokens(
|
idToken, refreshToken, accessToken, expiresIn, err := oc.oidcService.CreateTokens(
|
||||||
c.Request.Context(),
|
c,
|
||||||
input.Code,
|
input,
|
||||||
input.GrantType,
|
|
||||||
clientID,
|
|
||||||
clientSecret,
|
|
||||||
input.CodeVerifier,
|
|
||||||
input.RefreshToken,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
switch {
|
||||||
|
case errors.Is(err, &common.OidcAuthorizationPendingError{}):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "authorization_pending",
|
||||||
|
})
|
||||||
|
case errors.Is(err, &common.OidcSlowDownError{}):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "slow_down",
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
_ = c.Error(err)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,3 +621,60 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, oidcClientDto)
|
c.JSON(http.StatusOK, oidcClientDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
|
||||||
|
var input dto.OidcDeviceAuthorizationRequestDto
|
||||||
|
if err := c.ShouldBind(&input); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client id and secret can also be passed over the Authorization header
|
||||||
|
if input.ClientID == "" && input.ClientSecret == "" {
|
||||||
|
input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := oc.oidcService.CreateDeviceAuthorization(input)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
|
||||||
|
userCode := c.Query("code")
|
||||||
|
if userCode == "" {
|
||||||
|
_ = c.Error(&common.ValidationError{Message: "code is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get IP address and user agent from the request context
|
||||||
|
ipAddress := c.ClientIP()
|
||||||
|
userAgent := c.Request.UserAgent()
|
||||||
|
|
||||||
|
err := oc.oidcService.VerifyDeviceCode(c, userCode, c.GetString("userID"), ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) getDeviceCodeInfoHandler(c *gin.Context) {
|
||||||
|
userCode := c.Query("code")
|
||||||
|
if userCode == "" {
|
||||||
|
_ = c.Error(&common.ValidationError{Message: "code is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceCodeInfo, err := oc.oidcService.GetDeviceCodeInfo(c, userCode, c.GetString("userID"))
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, deviceCodeInfo)
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,8 +75,9 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
|
|||||||
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
|
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
|
||||||
"end_session_endpoint": appUrl + "/api/oidc/end-session",
|
"end_session_endpoint": appUrl + "/api/oidc/end-session",
|
||||||
"introspection_endpoint": appUrl + "/api/oidc/introspect",
|
"introspection_endpoint": appUrl + "/api/oidc/introspect",
|
||||||
|
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
|
||||||
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
||||||
"grant_types_supported": []string{"authorization_code", "refresh_token"},
|
"grant_types_supported": []string{"authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"},
|
||||||
"scopes_supported": []string{"openid", "profile", "email", "groups"},
|
"scopes_supported": []string{"openid", "profile", "email", "groups"},
|
||||||
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
|
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
|
||||||
"response_types_supported": []string{"code", "id_token"},
|
"response_types_supported": []string{"code", "id_token"},
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type AuthorizationRequiredDto struct {
|
|||||||
type OidcCreateTokensDto struct {
|
type OidcCreateTokensDto struct {
|
||||||
GrantType string `form:"grant_type" binding:"required"`
|
GrantType string `form:"grant_type" binding:"required"`
|
||||||
Code string `form:"code"`
|
Code string `form:"code"`
|
||||||
|
DeviceCode string `form:"device_code"`
|
||||||
ClientID string `form:"client_id"`
|
ClientID string `form:"client_id"`
|
||||||
ClientSecret string `form:"client_secret"`
|
ClientSecret string `form:"client_secret"`
|
||||||
CodeVerifier string `form:"code_verifier"`
|
CodeVerifier string `form:"code_verifier"`
|
||||||
@@ -90,3 +91,32 @@ type OidcIntrospectionResponseDto struct {
|
|||||||
Issuer string `json:"iss,omitempty"`
|
Issuer string `json:"iss,omitempty"`
|
||||||
Identifier string `json:"jti,omitempty"`
|
Identifier string `json:"jti,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OidcDeviceAuthorizationRequestDto struct {
|
||||||
|
ClientID string `form:"client_id" binding:"required"`
|
||||||
|
Scope string `form:"scope" binding:"required"`
|
||||||
|
ClientSecret string `form:"client_secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcDeviceAuthorizationResponseDto struct {
|
||||||
|
DeviceCode string `json:"device_code"`
|
||||||
|
UserCode string `json:"user_code"`
|
||||||
|
VerificationURI string `json:"verification_uri"`
|
||||||
|
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
RequiresAuthorization bool `json:"requires_authorization"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcDeviceTokenRequestDto struct {
|
||||||
|
GrantType string `form:"grant_type" binding:"required,eq=urn:ietf:params:oauth:grant-type:device_code"`
|
||||||
|
DeviceCode string `form:"device_code" binding:"required"`
|
||||||
|
ClientID string `form:"client_id"`
|
||||||
|
ClientSecret string `form:"client_secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceCodeInfoDto struct {
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
AuthorizationRequired bool `json:"authorizationRequired"`
|
||||||
|
Client OidcClientMetaDataDto `json:"client"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ type AuditLogData map[string]string //nolint:recvcheck
|
|||||||
type AuditLogEvent string //nolint:recvcheck
|
type AuditLogEvent string //nolint:recvcheck
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
|
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
|
||||||
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
|
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
|
||||||
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
|
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
|
||||||
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
|
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
|
||||||
|
AuditLogEventDeviceCodeAuthorization AuditLogEvent = "DEVICE_CODE_AUTHORIZATION"
|
||||||
|
AuditLogEventNewDeviceCodeAuthorization AuditLogEvent = "NEW_DEVICE_CODE_AUTHORIZATION"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Scan and Value methods for GORM to handle the custom type
|
// Scan and Value methods for GORM to handle the custom type
|
||||||
|
|||||||
@@ -87,3 +87,17 @@ func (cu *UrlList) Scan(value interface{}) error {
|
|||||||
func (cu UrlList) Value() (driver.Value, error) {
|
func (cu UrlList) Value() (driver.Value, error) {
|
||||||
return json.Marshal(cu)
|
return json.Marshal(cu)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OidcDeviceCode struct {
|
||||||
|
Base
|
||||||
|
DeviceCode string
|
||||||
|
UserCode string
|
||||||
|
Scope string
|
||||||
|
ExpiresAt datatype.DateTime
|
||||||
|
IsAuthorized bool
|
||||||
|
|
||||||
|
UserID *string
|
||||||
|
User User
|
||||||
|
ClientID string
|
||||||
|
Client OidcClient
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -180,45 +181,99 @@ func (s *OidcService) IsUserGroupAllowedToAuthorize(user model.User, client mode
|
|||||||
return isAllowedToAuthorize
|
return isAllowedToAuthorize
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OidcService) CreateTokens(ctx context.Context, code, grantType, clientID, clientSecret, codeVerifier, refreshToken string) (idToken string, accessToken string, newRefreshToken string, exp int, err error) {
|
func (s *OidcService) CreateTokens(ctx context.Context, input dto.OidcCreateTokensDto) (idToken string, accessToken string, newRefreshToken string, exp int, err error) {
|
||||||
switch grantType {
|
switch input.GrantType {
|
||||||
case "authorization_code":
|
case "authorization_code":
|
||||||
return s.createTokenFromAuthorizationCode(ctx, code, clientID, clientSecret, codeVerifier)
|
return s.createTokenFromAuthorizationCode(ctx, input.Code, input.ClientID, input.ClientSecret, input.CodeVerifier)
|
||||||
case "refresh_token":
|
case "refresh_token":
|
||||||
accessToken, newRefreshToken, exp, err = s.createTokenFromRefreshToken(ctx, refreshToken, clientID, clientSecret)
|
accessToken, newRefreshToken, exp, err = s.createTokenFromRefreshToken(ctx, input.RefreshToken, input.ClientID, input.ClientSecret)
|
||||||
return "", accessToken, newRefreshToken, exp, err
|
return "", accessToken, newRefreshToken, exp, err
|
||||||
|
case "urn:ietf:params:oauth:grant-type:device_code":
|
||||||
|
return s.createTokenFromDeviceCode(ctx, input.DeviceCode, input.ClientID, input.ClientSecret)
|
||||||
default:
|
default:
|
||||||
return "", "", "", 0, &common.OidcGrantTypeNotSupportedError{}
|
return "", "", "", 0, &common.OidcGrantTypeNotSupportedError{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, deviceCode, clientID string, clientSecret string) (idToken string, accessToken string, refreshToken string, exp int, err error) {
|
||||||
|
tx := s.db.Begin()
|
||||||
|
defer func() {
|
||||||
|
tx.Rollback()
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, err = s.VerifyClientCredentials(ctx, clientID, clientSecret, tx)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the device authorization from database with explicit query conditions
|
||||||
|
var deviceAuth model.OidcDeviceCode
|
||||||
|
if err := tx.WithContext(ctx).Preload("User").Where("device_code = ? AND client_id = ?", deviceCode, clientID).First(&deviceAuth).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return "", "", "", 0, &common.OidcInvalidDeviceCodeError{}
|
||||||
|
}
|
||||||
|
return "", "", "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if device code has expired
|
||||||
|
if time.Now().After(deviceAuth.ExpiresAt.ToTime()) {
|
||||||
|
return "", "", "", 0, &common.OidcDeviceCodeExpiredError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if device code has been authorized
|
||||||
|
if !deviceAuth.IsAuthorized || deviceAuth.UserID == nil {
|
||||||
|
return "", "", "", 0, &common.OidcAuthorizationPendingError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user claims for the ID token - ensure UserID is not nil
|
||||||
|
if deviceAuth.UserID == nil {
|
||||||
|
return "", "", "", 0, &common.OidcAuthorizationPendingError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
userClaims, err := s.getUserClaimsForClientInternal(ctx, *deviceAuth.UserID, clientID, tx)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly use the input clientID for the audience claim to ensure consistency
|
||||||
|
idToken, err = s.jwtService.GenerateIDToken(userClaims, clientID, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken, err = s.createRefreshToken(ctx, clientID, *deviceAuth.UserID, deviceAuth.Scope, tx)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err = s.jwtService.GenerateOauthAccessToken(deviceAuth.User, clientID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the used device code
|
||||||
|
if err := tx.WithContext(ctx).Delete(&deviceAuth).Error; err != nil {
|
||||||
|
return "", "", "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
return "", "", "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return idToken, accessToken, refreshToken, 3600, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, code, clientID, clientSecret, codeVerifier string) (idToken string, accessToken string, refreshToken string, exp int, err error) {
|
func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, code, clientID, clientSecret, codeVerifier string) (idToken string, accessToken string, refreshToken string, exp int, err error) {
|
||||||
tx := s.db.Begin()
|
tx := s.db.Begin()
|
||||||
defer func() {
|
defer func() {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var client model.OidcClient
|
client, err := s.VerifyClientCredentials(ctx, clientID, clientSecret, tx)
|
||||||
err = tx.
|
|
||||||
WithContext(ctx).
|
|
||||||
First(&client, "id = ?", clientID).
|
|
||||||
Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", "", 0, err
|
return "", "", "", 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the client secret if the client is not public
|
|
||||||
if !client.IsPublic {
|
|
||||||
if clientID == "" || clientSecret == "" {
|
|
||||||
return "", "", "", 0, &common.OidcMissingClientCredentialsError{}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", 0, &common.OidcClientSecretInvalidError{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var authorizationCodeMetaData model.OidcAuthorizationCode
|
var authorizationCodeMetaData model.OidcAuthorizationCode
|
||||||
err = tx.
|
err = tx.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
@@ -287,28 +342,11 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, refreshTo
|
|||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Get the client to check if it's public
|
_, err = s.VerifyClientCredentials(ctx, clientID, clientSecret, tx)
|
||||||
var client model.OidcClient
|
|
||||||
err = tx.
|
|
||||||
WithContext(ctx).
|
|
||||||
First(&client, "id = ?", clientID).
|
|
||||||
Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", 0, err
|
return "", "", 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the client secret if the client is not public
|
|
||||||
if !client.IsPublic {
|
|
||||||
if clientID == "" || clientSecret == "" {
|
|
||||||
return "", "", 0, &common.OidcMissingClientCredentialsError{}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
|
|
||||||
if err != nil {
|
|
||||||
return "", "", 0, &common.OidcClientSecretInvalidError{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify refresh token
|
// Verify refresh token
|
||||||
var storedRefreshToken model.OidcRefreshToken
|
var storedRefreshToken model.OidcRefreshToken
|
||||||
err = tx.
|
err = tx.
|
||||||
@@ -363,19 +401,9 @@ func (s *OidcService) IntrospectToken(clientID, clientSecret, tokenString string
|
|||||||
return introspectDto, &common.OidcMissingClientCredentialsError{}
|
return introspectDto, &common.OidcMissingClientCredentialsError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the client to check if we are authorized.
|
_, err = s.VerifyClientCredentials(context.Background(), clientID, clientSecret, s.db)
|
||||||
var client model.OidcClient
|
if err != nil {
|
||||||
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
|
return introspectDto, err
|
||||||
return introspectDto, &common.OidcClientSecretInvalidError{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the client secret. This endpoint may not be used by public clients.
|
|
||||||
if client.IsPublic {
|
|
||||||
return introspectDto, &common.OidcClientSecretInvalidError{}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret)); err != nil {
|
|
||||||
return introspectDto, &common.OidcClientSecretInvalidError{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := s.jwtService.VerifyOauthAccessToken(tokenString)
|
token, err := s.jwtService.VerifyOauthAccessToken(tokenString)
|
||||||
@@ -968,6 +996,162 @@ func (s *OidcService) getCallbackURL(urls []string, inputCallbackURL string) (ca
|
|||||||
return "", &common.OidcInvalidCallbackURLError{}
|
return "", &common.OidcInvalidCallbackURLError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) CreateDeviceAuthorization(input dto.OidcDeviceAuthorizationRequestDto) (*dto.OidcDeviceAuthorizationResponseDto, error) {
|
||||||
|
client, err := s.VerifyClientCredentials(context.Background(), input.ClientID, input.ClientSecret, s.db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate codes
|
||||||
|
deviceCode, err := utils.GenerateRandomAlphanumericString(32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
userCode, err := utils.GenerateRandomAlphanumericString(8)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create device authorization
|
||||||
|
deviceAuth := &model.OidcDeviceCode{
|
||||||
|
DeviceCode: deviceCode,
|
||||||
|
UserCode: userCode,
|
||||||
|
Scope: input.Scope,
|
||||||
|
ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
|
||||||
|
IsAuthorized: false,
|
||||||
|
ClientID: client.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Create(deviceAuth).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.OidcDeviceAuthorizationResponseDto{
|
||||||
|
DeviceCode: deviceCode,
|
||||||
|
UserCode: userCode,
|
||||||
|
VerificationURI: common.EnvConfig.AppURL + "/device",
|
||||||
|
VerificationURIComplete: common.EnvConfig.AppURL + "/device?code=" + userCode,
|
||||||
|
ExpiresIn: 900, // 15 minutes
|
||||||
|
Interval: 5,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, userID string, ipAddress string, userAgent string) error {
|
||||||
|
tx := s.db.Begin()
|
||||||
|
defer func() {
|
||||||
|
tx.Rollback()
|
||||||
|
}()
|
||||||
|
|
||||||
|
var deviceAuth model.OidcDeviceCode
|
||||||
|
if err := tx.WithContext(ctx).Preload("Client.AllowedUserGroups").First(&deviceAuth, "user_code = ?", userCode).Error; err != nil {
|
||||||
|
log.Printf("Error finding device code with user_code %s: %v", userCode, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(deviceAuth.ExpiresAt.ToTime()) {
|
||||||
|
return &common.OidcDeviceCodeExpiredError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user group is allowed to authorize the client
|
||||||
|
var user model.User
|
||||||
|
if err := tx.WithContext(ctx).Preload("UserGroups").First(&user, "id = ?", userID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.IsUserGroupAllowedToAuthorize(user, deviceAuth.Client) {
|
||||||
|
return &common.OidcAccessDeniedError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.WithContext(ctx).Preload("Client").First(&deviceAuth, "user_code = ?", userCode).Error; err != nil {
|
||||||
|
log.Printf("Error finding device code with user_code %s: %v", userCode, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(deviceAuth.ExpiresAt.ToTime()) {
|
||||||
|
return &common.OidcDeviceCodeExpiredError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceAuth.UserID = &userID
|
||||||
|
deviceAuth.IsAuthorized = true
|
||||||
|
|
||||||
|
if err := tx.WithContext(ctx).Save(&deviceAuth).Error; err != nil {
|
||||||
|
log.Printf("Error saving device auth: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the update was successful
|
||||||
|
var verifiedAuth model.OidcDeviceCode
|
||||||
|
if err := tx.WithContext(ctx).First(&verifiedAuth, "device_code = ?", deviceAuth.DeviceCode).Error; err != nil {
|
||||||
|
log.Printf("Error verifying update: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user authorization if needed
|
||||||
|
hasAuthorizedClient, err := s.hasAuthorizedClientInternal(ctx, deviceAuth.ClientID, userID, deviceAuth.Scope, tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasAuthorizedClient {
|
||||||
|
userAuthorizedClient := model.UserAuthorizedOidcClient{
|
||||||
|
UserID: userID,
|
||||||
|
ClientID: deviceAuth.ClientID,
|
||||||
|
Scope: deviceAuth.Scope,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.WithContext(ctx).Create(&userAuthorizedClient).Error; err != nil {
|
||||||
|
if !errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// If duplicate, update scope
|
||||||
|
if err := tx.WithContext(ctx).Model(&model.UserAuthorizedOidcClient{}).
|
||||||
|
Where("user_id = ? AND client_id = ?", userID, deviceAuth.ClientID).
|
||||||
|
Update("scope", deviceAuth.Scope).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.auditLogService.Create(ctx, model.AuditLogEventNewDeviceCodeAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": deviceAuth.Client.Name}, tx)
|
||||||
|
} else {
|
||||||
|
s.auditLogService.Create(ctx, model.AuditLogEventDeviceCodeAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": deviceAuth.Client.Name}, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit().Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) GetDeviceCodeInfo(ctx context.Context, userCode string, userID string) (*dto.DeviceCodeInfoDto, error) {
|
||||||
|
var deviceAuth model.OidcDeviceCode
|
||||||
|
if err := s.db.Preload("Client").First(&deviceAuth, "user_code = ?", userCode).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, &common.OidcInvalidDeviceCodeError{}
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(deviceAuth.ExpiresAt.ToTime()) {
|
||||||
|
return nil, &common.OidcDeviceCodeExpiredError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user has already authorized this client with this scope
|
||||||
|
hasAuthorizedClient := false
|
||||||
|
if userID != "" {
|
||||||
|
var err error
|
||||||
|
hasAuthorizedClient, err = s.HasAuthorizedClient(ctx, deviceAuth.ClientID, userID, deviceAuth.Scope)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.DeviceCodeInfoDto{
|
||||||
|
Client: dto.OidcClientMetaDataDto{
|
||||||
|
ID: deviceAuth.Client.ID,
|
||||||
|
Name: deviceAuth.Client.Name,
|
||||||
|
HasLogo: deviceAuth.Client.HasLogo,
|
||||||
|
},
|
||||||
|
Scope: deviceAuth.Scope,
|
||||||
|
AuthorizationRequired: !hasAuthorizedClient,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) {
|
func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) {
|
||||||
refreshToken, err := utils.GenerateRandomAlphanumericString(40)
|
refreshToken, err := utils.GenerateRandomAlphanumericString(40)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -996,3 +1180,25 @@ func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, u
|
|||||||
|
|
||||||
return refreshToken, nil
|
return refreshToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) VerifyClientCredentials(ctx context.Context, clientID, clientSecret string, tx *gorm.DB) (model.OidcClient, error) {
|
||||||
|
if clientID == "" {
|
||||||
|
return model.OidcClient{}, &common.OidcMissingClientCredentialsError{}
|
||||||
|
}
|
||||||
|
var client model.OidcClient
|
||||||
|
err := tx.
|
||||||
|
WithContext(ctx).
|
||||||
|
First(&client, "id = ?", clientID).
|
||||||
|
Error
|
||||||
|
if err != nil {
|
||||||
|
return model.OidcClient{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !client.IsPublic {
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret)); err != nil {
|
||||||
|
return model.OidcClient{}, &common.OidcClientSecretInvalidError{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE oidc_device_codes;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE oidc_device_codes
|
||||||
|
(
|
||||||
|
id UUID NOT NULL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
device_code TEXT NOT NULL UNIQUE,
|
||||||
|
user_code TEXT NOT NULL UNIQUE,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
is_authorized BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
user_id UUID REFERENCES users ON DELETE CASCADE,
|
||||||
|
client_id UUID NOT NULL REFERENCES oidc_clients ON DELETE CASCADE
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE oidc_device_codes;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE oidc_device_codes
|
||||||
|
(
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
created_at DATETIME,
|
||||||
|
device_code TEXT NOT NULL UNIQUE,
|
||||||
|
user_code TEXT NOT NULL UNIQUE,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
is_authorized BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
user_id TEXT REFERENCES users ON DELETE CASCADE,
|
||||||
|
client_id TEXT NOT NULL REFERENCES oidc_clients ON DELETE CASCADE
|
||||||
|
);
|
||||||
@@ -342,5 +342,9 @@
|
|||||||
"show_code": "Show Code",
|
"show_code": "Show Code",
|
||||||
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.",
|
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.",
|
||||||
"api_key_expiration": "API Key Expiration",
|
"api_key_expiration": "API Key Expiration",
|
||||||
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire."
|
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
|
||||||
|
"authorize_device": "Authorize Device",
|
||||||
|
"the_device_has_been_authorized": "The device has been authorized.",
|
||||||
|
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
|
||||||
|
"authorize": "Authorize"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const authenticationHandle: Handle = async ({ event, resolve }) => {
|
|||||||
const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME));
|
const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME));
|
||||||
|
|
||||||
const isUnauthenticatedOnlyPath = event.url.pathname.startsWith('/login') || event.url.pathname.startsWith('/lc')
|
const isUnauthenticatedOnlyPath = event.url.pathname.startsWith('/login') || event.url.pathname.startsWith('/lc')
|
||||||
const isPublicPath = ['/authorize', '/health'].includes(event.url.pathname);
|
const isPublicPath = ['/authorize', '/device', '/health'].includes(event.url.pathname);
|
||||||
const isAdminPath = event.url.pathname.startsWith('/settings/admin');
|
const isAdminPath = event.url.pathname.startsWith('/settings/admin');
|
||||||
|
|
||||||
if (!isUnauthenticatedOnlyPath && !isPublicPath && !isSignedIn) {
|
if (!isUnauthenticatedOnlyPath && !isPublicPath && !isSignedIn) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import Logo from '../logo.svelte';
|
import Logo from '../logo.svelte';
|
||||||
import HeaderAvatar from './header-avatar.svelte';
|
import HeaderAvatar from './header-avatar.svelte';
|
||||||
|
|
||||||
const authUrls = [/^\/authorize$/, /^\/login(?:\/.*)?$/, /^\/logout$/];
|
const authUrls = [/^\/authorize$/, /^\/device$/, /^\/login(?:\/.*)?$/, /^\/logout$/];
|
||||||
|
|
||||||
let isAuthPage = $derived(
|
let isAuthPage = $derived(
|
||||||
!page.error && authUrls.some((pattern) => pattern.test(page.url.pathname))
|
!page.error && authUrls.some((pattern) => pattern.test(page.url.pathname))
|
||||||
|
|||||||
27
frontend/src/lib/components/scope-list.svelte
Normal file
27
frontend/src/lib/components/scope-list.svelte
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import { LucideMail, LucideUser, LucideUsers } from 'lucide-svelte';
|
||||||
|
import ScopeItem from './scope-item.svelte';
|
||||||
|
|
||||||
|
let { scope }: { scope: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3" data-testid="scopes">
|
||||||
|
{#if scope!.includes('email')}
|
||||||
|
<ScopeItem icon={LucideMail} name={m.email()} description={m.view_your_email_address()} />
|
||||||
|
{/if}
|
||||||
|
{#if scope!.includes('profile')}
|
||||||
|
<ScopeItem
|
||||||
|
icon={LucideUser}
|
||||||
|
name={m.profile()}
|
||||||
|
description={m.view_your_profile_information()}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if scope!.includes('groups')}
|
||||||
|
<ScopeItem
|
||||||
|
icon={LucideUsers}
|
||||||
|
name={m.groups()}
|
||||||
|
description={m.view_the_groups_you_are_a_member_of()}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AuthorizeResponse,
|
AuthorizeResponse,
|
||||||
|
OidcDeviceCodeInfo,
|
||||||
OidcClient,
|
OidcClient,
|
||||||
OidcClientCreate,
|
OidcClientCreate,
|
||||||
OidcClientMetaData,
|
OidcClientMetaData,
|
||||||
@@ -8,6 +9,7 @@ import type {
|
|||||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||||
import APIService from './api-service';
|
import APIService from './api-service';
|
||||||
|
|
||||||
|
|
||||||
class OidcService extends APIService {
|
class OidcService extends APIService {
|
||||||
async authorize(
|
async authorize(
|
||||||
clientId: string,
|
clientId: string,
|
||||||
@@ -92,6 +94,15 @@ class OidcService extends APIService {
|
|||||||
const res = await this.api.put(`/oidc/clients/${id}/allowed-user-groups`, { userGroupIds });
|
const res = await this.api.put(`/oidc/clients/${id}/allowed-user-groups`, { userGroupIds });
|
||||||
return res.data as OidcClientWithAllowedUserGroups;
|
return res.data as OidcClientWithAllowedUserGroups;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async verifyDeviceCode(userCode: string) {
|
||||||
|
return await this.api.post(`/oidc/device/verify?code=${userCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDeviceCodeInfo(userCode: string): Promise<OidcDeviceCodeInfo> {
|
||||||
|
const response = await this.api.get(`/oidc/device/info?code=${userCode}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default OidcService;
|
export default OidcService;
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ export type OidcClientCreateWithLogo = OidcClientCreate & {
|
|||||||
logo: File | null | undefined;
|
logo: File | null | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OidcDeviceCodeInfo = {
|
||||||
|
scope: string;
|
||||||
|
authorizationRequired: boolean;
|
||||||
|
client: OidcClientMetaData;
|
||||||
|
};
|
||||||
|
|
||||||
export type AuthorizeResponse = {
|
export type AuthorizeResponse = {
|
||||||
code: string;
|
code: string;
|
||||||
callbackURL: string;
|
callbackURL: string;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import ClientProviderImages from './components/client-provider-images.svelte';
|
import ClientProviderImages from './components/client-provider-images.svelte';
|
||||||
import ScopeItem from './components/scope-item.svelte';
|
import ScopeItem from '$lib/components/scope-item.svelte';
|
||||||
|
|
||||||
const webauthnService = new WebAuthnService();
|
const webauthnService = new WebAuthnService();
|
||||||
const oidService = new OidcService();
|
const oidService = new OidcService();
|
||||||
|
|||||||
9
frontend/src/routes/device/+page.server.ts
Normal file
9
frontend/src/routes/device/+page.server.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
|
||||||
|
return {
|
||||||
|
code
|
||||||
|
};
|
||||||
|
};
|
||||||
124
frontend/src/routes/device/+page.svelte
Normal file
124
frontend/src/routes/device/+page.svelte
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||||
|
import ScopeList from '$lib/components/scope-list.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import OIDCService from '$lib/services/oidc-service';
|
||||||
|
import WebAuthnService from '$lib/services/webauthn-service';
|
||||||
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
|
import userStore from '$lib/stores/user-store';
|
||||||
|
import type { OidcDeviceCodeInfo } from '$lib/types/oidc.type';
|
||||||
|
import { getAxiosErrorMessage } from '$lib/utils/error-util';
|
||||||
|
import { startAuthentication } from '@simplewebauthn/browser';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import ClientProviderImages from '../authorize/components/client-provider-images.svelte';
|
||||||
|
import LoginLogoErrorSuccessIndicator from '../login/components/login-logo-error-success-indicator.svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
const oidcService = new OIDCService();
|
||||||
|
const webauthnService = new WebAuthnService();
|
||||||
|
|
||||||
|
let userCode = $state(data.code || '');
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let deviceInfo: OidcDeviceCodeInfo | undefined = $state();
|
||||||
|
let success = $state(false);
|
||||||
|
let errorMessage: string | null = $state(null);
|
||||||
|
let authorizationRequired = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (data.code && $userStore) {
|
||||||
|
authorize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function authorize() {
|
||||||
|
isLoading = true;
|
||||||
|
try {
|
||||||
|
// Get access token if not signed in
|
||||||
|
if (!$userStore) {
|
||||||
|
const loginOptions = await webauthnService.getLoginOptions();
|
||||||
|
const authResponse = await startAuthentication(loginOptions);
|
||||||
|
const user = await webauthnService.finishLogin(authResponse);
|
||||||
|
userStore.setUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = await oidcService.getDeviceCodeInfo(userCode);
|
||||||
|
deviceInfo = info;
|
||||||
|
|
||||||
|
if (info.authorizationRequired && !authorizationRequired) {
|
||||||
|
authorizationRequired = true;
|
||||||
|
isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await oidcService.verifyDeviceCode(userCode);
|
||||||
|
|
||||||
|
success = true;
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage = getAxiosErrorMessage(e);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{m.authorize_device()}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<SignInWrapper
|
||||||
|
animate={!$appConfigStore.disableAnimations}
|
||||||
|
showAlternativeSignInMethodButton={$userStore == null}
|
||||||
|
>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
{#if deviceInfo?.client}
|
||||||
|
<ClientProviderImages client={deviceInfo.client} {success} error={!!errorMessage} />
|
||||||
|
{:else}
|
||||||
|
<LoginLogoErrorSuccessIndicator {success} error={!!errorMessage} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<h1 class="font-playfair mt-5 text-4xl font-bold">{m.authorize_device()}</h1>
|
||||||
|
{#if errorMessage}
|
||||||
|
<p class="text-muted-foreground mt-2">
|
||||||
|
{errorMessage}. {m.please_try_again()}
|
||||||
|
</p>
|
||||||
|
{:else if success}
|
||||||
|
<p class="text-muted-foreground mt-2">{m.the_device_has_been_authorized()}</p>
|
||||||
|
{:else if authorizationRequired}
|
||||||
|
<div transition:slide={{ duration: 300 }}>
|
||||||
|
<Card.Root class="mt-6">
|
||||||
|
<Card.Header class="pb-5">
|
||||||
|
<p class="text-muted-foreground text-start">
|
||||||
|
{@html m.client_wants_to_access_the_following_information({
|
||||||
|
client: deviceInfo!.client.name
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content data-testid="scopes">
|
||||||
|
<ScopeList scope={deviceInfo!.scope} />
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted-foreground mt-2">{m.enter_code_displayed_in_previous_step()}</p>
|
||||||
|
<form id="device-code-form" onsubmit={authorize} class="w-full max-w-[450px]">
|
||||||
|
<Input id="user-code" class="mt-7" placeholder={m.code()} bind:value={userCode} type="text" />
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{#if !success}
|
||||||
|
<div class="mt-10 flex w-full justify-stretch gap-2">
|
||||||
|
<Button href="/" class="w-full" variant="secondary">{m.cancel()}</Button>
|
||||||
|
{#if !errorMessage}
|
||||||
|
<Button form="device-code-form" class="w-full" onclick={authorize} {isLoading}
|
||||||
|
>{m.authorize()}</Button
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<Button class="w-full" on:click={() => (errorMessage = null)}>{m.try_again()}</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</SignInWrapper>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<title>{m.sign_in()}</title>
|
<title>{m.sign_in()}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<SignInWrapper animate={!$appConfigStore.disableAnimations}>
|
<SignInWrapper>
|
||||||
<div class="flex h-full flex-col justify-center">
|
<div class="flex h-full flex-col justify-center">
|
||||||
<div class="bg-muted mx-auto rounded-2xl p-3">
|
<div class="bg-muted mx-auto rounded-2xl p-3">
|
||||||
<Logo class="h-10 w-10" />
|
<Logo class="h-10 w-10" />
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const oidcClients = {
|
|||||||
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',
|
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',
|
||||||
name: 'Immich',
|
name: 'Immich',
|
||||||
callbackUrl: 'http://immich/auth/callback',
|
callbackUrl: 'http://immich/auth/callback',
|
||||||
secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x'
|
secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x',
|
||||||
},
|
},
|
||||||
pingvinShare: {
|
pingvinShare: {
|
||||||
name: 'Pingvin Share',
|
name: 'Pingvin Share',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import test, { expect } from '@playwright/test';
|
import test, { expect } from '@playwright/test';
|
||||||
import { accessTokens, idTokens, oidcClients, refreshTokens, users } from './data';
|
import { accessTokens, idTokens, oidcClients, refreshTokens, users } from './data';
|
||||||
import { cleanupBackend } from './utils/cleanup.util';
|
import { cleanupBackend } from './utils/cleanup.util';
|
||||||
|
import oidcUtil from './utils/oidc.util';
|
||||||
import passkeyUtil from './utils/passkey.util';
|
import passkeyUtil from './utils/passkey.util';
|
||||||
|
|
||||||
test.beforeEach(cleanupBackend);
|
test.beforeEach(cleanupBackend);
|
||||||
@@ -277,3 +278,99 @@ test.describe('Introspection endpoint', () => {
|
|||||||
expect(introspectionResponse.status()).toBe(400);
|
expect(introspectionResponse.status()).toBe(400);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Authorize new client with device authorization flow', async ({ page }) => {
|
||||||
|
const client = oidcClients.immich;
|
||||||
|
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
|
||||||
|
|
||||||
|
await page.goto(`/device?code=${userCode}`);
|
||||||
|
|
||||||
|
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible();
|
||||||
|
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Authorize' }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('paragraph').filter({ hasText: 'The device has been authorized.' })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Authorize new client with device authorization flow while not signed in', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await page.context().clearCookies();
|
||||||
|
const client = oidcClients.immich;
|
||||||
|
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
|
||||||
|
|
||||||
|
await page.goto(`/device?code=${userCode}`);
|
||||||
|
|
||||||
|
await (await passkeyUtil.init(page)).addPasskey();
|
||||||
|
await page.getByRole('button', { name: 'Authorize' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible();
|
||||||
|
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Authorize' }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('paragraph').filter({ hasText: 'The device has been authorized.' })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Authorize existing client with device authorization flow', async ({ page }) => {
|
||||||
|
const client = oidcClients.nextcloud;
|
||||||
|
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
|
||||||
|
|
||||||
|
await page.goto(`/device?code=${userCode}`);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('paragraph').filter({ hasText: 'The device has been authorized.' })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Authorize existing client with device authorization flow while not signed in', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await page.context().clearCookies();
|
||||||
|
const client = oidcClients.nextcloud;
|
||||||
|
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
|
||||||
|
|
||||||
|
await page.goto(`/device?code=${userCode}`);
|
||||||
|
|
||||||
|
await (await passkeyUtil.init(page)).addPasskey();
|
||||||
|
await page.getByRole('button', { name: 'Authorize' }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('paragraph').filter({ hasText: 'The device has been authorized.' })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Authorize client with device authorization flow with invalid code', async ({ page }) => {
|
||||||
|
await page.goto('/device?code=invalid-code');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('paragraph').filter({ hasText: 'Invalid device code.' })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Authorize new client with device authorization with user group not allowed', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await page.context().clearCookies();
|
||||||
|
const client = oidcClients.immich;
|
||||||
|
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
|
||||||
|
|
||||||
|
await page.goto(`/device?code=${userCode}`);
|
||||||
|
|
||||||
|
await (await passkeyUtil.init(page)).addPasskey('craig');
|
||||||
|
await page.getByRole('button', { name: 'Authorize' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible();
|
||||||
|
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Authorize' }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('paragraph').filter({ hasText: "You're not allowed to access this service." })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
22
frontend/tests/utils/oidc.util.ts
Normal file
22
frontend/tests/utils/oidc.util.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
async function getUserCode(page: Page, clientId: string, clientSecret: string) {
|
||||||
|
const response = await page.request
|
||||||
|
.post('/api/oidc/device/authorize', {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
scope: 'openid profile email'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((r) => r.json());
|
||||||
|
|
||||||
|
return response.user_code;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getUserCode
|
||||||
|
};
|
||||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "pocket-id",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user