mirror of
https://github.com/nikdoof/pocket-id.git
synced 2025-12-13 23:02:17 +00:00
feat: add PKCE support
This commit is contained in:
@@ -11,7 +11,7 @@ Additionally, what makes Pocket ID special is that it only supports [passkey](ht
|
|||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Pocket ID is in its early stages and may contain bugs. There might be OIDC features that are not yet implemented. If you encounter any issues, please open an issue. For example PKCE is not yet implemented.
|
> Pocket ID is in its early stages and may contain bugs. There might be OIDC features that are not yet implemented. If you encounter any issues, please open an issue.
|
||||||
|
|
||||||
### Before you start
|
### Before you start
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyR
|
|||||||
type ClientIdOrSecretNotProvidedError struct{}
|
type ClientIdOrSecretNotProvidedError struct{}
|
||||||
|
|
||||||
func (e *ClientIdOrSecretNotProvidedError) Error() string {
|
func (e *ClientIdOrSecretNotProvidedError) Error() string {
|
||||||
return "Client id and secret not provided"
|
return "Client id or secret not provided"
|
||||||
}
|
}
|
||||||
func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
|
func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
@@ -146,3 +146,17 @@ func (e *AccountEditNotAllowedError) Error() string {
|
|||||||
return "You are not allowed to edit your account"
|
return "You are not allowed to edit your account"
|
||||||
}
|
}
|
||||||
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
|
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||||
|
|
||||||
|
type OidcInvalidCodeVerifierError struct{}
|
||||||
|
|
||||||
|
func (e *OidcInvalidCodeVerifierError) Error() string {
|
||||||
|
return "Invalid code verifier"
|
||||||
|
}
|
||||||
|
func (e *OidcInvalidCodeVerifierError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type OidcMissingCodeChallengeError struct{}
|
||||||
|
|
||||||
|
func (e *OidcMissingCodeChallengeError) Error() string {
|
||||||
|
return "Missing code challenge"
|
||||||
|
}
|
||||||
|
func (e *OidcMissingCodeChallengeError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
|
||||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||||
@@ -80,7 +79,10 @@ func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||||
var input dto.OidcIdTokenDto
|
// Disable cors for this endpoint
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
var input dto.OidcCreateTokensDto
|
||||||
|
|
||||||
if err := c.ShouldBind(&input); err != nil {
|
if err := c.ShouldBind(&input); err != nil {
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
@@ -91,16 +93,11 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
|||||||
clientSecret := input.ClientSecret
|
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 clientID == "" && clientSecret == "" {
|
||||||
var ok bool
|
clientID, clientSecret, _ = c.Request.BasicAuth()
|
||||||
clientID, clientSecret, ok = c.Request.BasicAuth()
|
|
||||||
if !ok {
|
|
||||||
c.Error(&common.ClientIdOrSecretNotProvidedError{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret)
|
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret, input.CodeVerifier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -9,19 +9,23 @@ type PublicOidcClientDto struct {
|
|||||||
type OidcClientDto struct {
|
type OidcClientDto struct {
|
||||||
PublicOidcClientDto
|
PublicOidcClientDto
|
||||||
CallbackURLs []string `json:"callbackURLs"`
|
CallbackURLs []string `json:"callbackURLs"`
|
||||||
|
IsPublic bool `json:"isPublic"`
|
||||||
CreatedBy UserDto `json:"createdBy"`
|
CreatedBy UserDto `json:"createdBy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientCreateDto struct {
|
type OidcClientCreateDto struct {
|
||||||
Name string `json:"name" binding:"required,max=50"`
|
Name string `json:"name" binding:"required,max=50"`
|
||||||
CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"`
|
CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"`
|
||||||
|
IsPublic bool `json:"isPublic"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizeOidcClientRequestDto struct {
|
type AuthorizeOidcClientRequestDto struct {
|
||||||
ClientID string `json:"clientID" binding:"required"`
|
ClientID string `json:"clientID" binding:"required"`
|
||||||
Scope string `json:"scope" binding:"required"`
|
Scope string `json:"scope" binding:"required"`
|
||||||
CallbackURL string `json:"callbackURL"`
|
CallbackURL string `json:"callbackURL"`
|
||||||
Nonce string `json:"nonce"`
|
Nonce string `json:"nonce"`
|
||||||
|
CodeChallenge string `json:"codeChallenge"`
|
||||||
|
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizeOidcClientResponseDto struct {
|
type AuthorizeOidcClientResponseDto struct {
|
||||||
@@ -29,9 +33,10 @@ type AuthorizeOidcClientResponseDto struct {
|
|||||||
CallbackURL string `json:"callbackURL"`
|
CallbackURL string `json:"callbackURL"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcIdTokenDto struct {
|
type OidcCreateTokensDto struct {
|
||||||
GrantType string `form:"grant_type" binding:"required"`
|
GrantType string `form:"grant_type" binding:"required"`
|
||||||
Code string `form:"code" binding:"required"`
|
Code string `form:"code" binding:"required"`
|
||||||
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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-contrib/cors"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CorsMiddleware struct{}
|
type CorsMiddleware struct{}
|
||||||
@@ -15,10 +12,22 @@ func NewCorsMiddleware() *CorsMiddleware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *CorsMiddleware) Add() gin.HandlerFunc {
|
func (m *CorsMiddleware) Add() gin.HandlerFunc {
|
||||||
return cors.New(cors.Config{
|
return func(c *gin.Context) {
|
||||||
AllowOrigins: []string{common.EnvConfig.AppURL},
|
// Allow all origins for the token endpoint
|
||||||
AllowMethods: []string{"*"},
|
if c.FullPath() == "/api/oidc/token" {
|
||||||
AllowHeaders: []string{"*"},
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
MaxAge: 12 * time.Hour,
|
} else {
|
||||||
})
|
c.Writer.Header().Set("Access-Control-Allow-Origin", common.EnvConfig.AppURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Headers", "*")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")
|
||||||
|
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ type UserAuthorizedOidcClient struct {
|
|||||||
type OidcAuthorizationCode struct {
|
type OidcAuthorizationCode struct {
|
||||||
Base
|
Base
|
||||||
|
|
||||||
Code string
|
Code string
|
||||||
Scope string
|
Scope string
|
||||||
Nonce string
|
Nonce string
|
||||||
ExpiresAt datatype.DateTime
|
CodeChallenge *string
|
||||||
|
CodeChallengeMethodSha256 *bool
|
||||||
|
ExpiresAt datatype.DateTime
|
||||||
|
|
||||||
UserID string
|
UserID string
|
||||||
User User
|
User User
|
||||||
@@ -39,6 +41,7 @@ type OidcClient struct {
|
|||||||
CallbackURLs CallbackURLs
|
CallbackURLs CallbackURLs
|
||||||
ImageType *string
|
ImageType *string
|
||||||
HasLogo bool `gorm:"-"`
|
HasLogo bool `gorm:"-"`
|
||||||
|
IsPublic bool
|
||||||
|
|
||||||
CreatedByID string
|
CreatedByID string
|
||||||
CreatedBy User
|
CreatedBy User
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
@@ -39,16 +41,20 @@ func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID,
|
|||||||
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
|
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
|
||||||
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
|
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
|
||||||
|
|
||||||
|
if userAuthorizedOIDCClient.Client.IsPublic && input.CodeChallenge == "" {
|
||||||
|
return "", "", &common.OidcMissingCodeChallengeError{}
|
||||||
|
}
|
||||||
|
|
||||||
if userAuthorizedOIDCClient.Scope != input.Scope {
|
if userAuthorizedOIDCClient.Scope != input.Scope {
|
||||||
return "", "", &common.OidcMissingAuthorizationError{}
|
return "", "", &common.OidcMissingAuthorizationError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
callbackURL, err := getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
|
callbackURL, err := s.getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
|
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
@@ -64,7 +70,11 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
|
|||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
callbackURL, err := getCallbackURL(client, input.CallbackURL)
|
if client.IsPublic && input.CodeChallenge == "" {
|
||||||
|
return "", "", &common.OidcMissingCodeChallengeError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
callbackURL, err := s.getCallbackURL(client, input.CallbackURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
@@ -83,7 +93,7 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
|
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
@@ -93,31 +103,41 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
|
|||||||
return code, callbackURL, nil
|
return code, callbackURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) {
|
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret, codeVerifier string) (string, string, error) {
|
||||||
if grantType != "authorization_code" {
|
if grantType != "authorization_code" {
|
||||||
return "", "", &common.OidcGrantTypeNotSupportedError{}
|
return "", "", &common.OidcGrantTypeNotSupportedError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if clientID == "" || clientSecret == "" {
|
|
||||||
return "", "", &common.OidcMissingClientCredentialsError{}
|
|
||||||
}
|
|
||||||
|
|
||||||
var client model.OidcClient
|
var client model.OidcClient
|
||||||
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
|
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
|
// Verify the client secret if the client is not public
|
||||||
if err != nil {
|
if !client.IsPublic {
|
||||||
return "", "", &common.OidcClientSecretInvalidError{}
|
if clientID == "" || clientSecret == "" {
|
||||||
|
return "", "", &common.OidcMissingClientCredentialsError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", &common.OidcClientSecretInvalidError{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var authorizationCodeMetaData model.OidcAuthorizationCode
|
var authorizationCodeMetaData model.OidcAuthorizationCode
|
||||||
err = s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error
|
err := s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the client is public, the code verifier must match the code challenge
|
||||||
|
if client.IsPublic {
|
||||||
|
if !s.validateCodeVerifier(codeVerifier, *authorizationCodeMetaData.CodeChallenge, *authorizationCodeMetaData.CodeChallengeMethodSha256) {
|
||||||
|
return "", "", &common.OidcInvalidCodeVerifierError{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
|
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
|
||||||
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
||||||
}
|
}
|
||||||
@@ -186,6 +206,7 @@ func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDt
|
|||||||
|
|
||||||
client.Name = input.Name
|
client.Name = input.Name
|
||||||
client.CallbackURLs = input.CallbackURLs
|
client.CallbackURLs = input.CallbackURLs
|
||||||
|
client.IsPublic = input.IsPublic
|
||||||
|
|
||||||
if err := s.db.Save(&client).Error; err != nil {
|
if err := s.db.Save(&client).Error; err != nil {
|
||||||
return model.OidcClient{}, err
|
return model.OidcClient{}, err
|
||||||
@@ -358,19 +379,23 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
|||||||
return claims, nil
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string) (string, error) {
|
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) {
|
||||||
randomString, err := utils.GenerateRandomAlphanumericString(32)
|
randomString, err := utils.GenerateRandomAlphanumericString(32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
codeChallengeMethodSha256 := strings.ToUpper(codeChallengeMethod) == "S256"
|
||||||
|
|
||||||
oidcAuthorizationCode := model.OidcAuthorizationCode{
|
oidcAuthorizationCode := model.OidcAuthorizationCode{
|
||||||
ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
|
ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
|
||||||
Code: randomString,
|
Code: randomString,
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Scope: scope,
|
Scope: scope,
|
||||||
Nonce: nonce,
|
Nonce: nonce,
|
||||||
|
CodeChallenge: &codeChallenge,
|
||||||
|
CodeChallengeMethodSha256: &codeChallengeMethodSha256,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.Create(&oidcAuthorizationCode).Error; err != nil {
|
if err := s.db.Create(&oidcAuthorizationCode).Error; err != nil {
|
||||||
@@ -380,7 +405,23 @@ func (s *OidcService) createAuthorizationCode(clientID string, userID string, sc
|
|||||||
return randomString, nil
|
return randomString, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackURL string, err error) {
|
func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool {
|
||||||
|
if !codeChallengeMethodSha256 {
|
||||||
|
return codeVerifier == codeChallenge
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute SHA-256 hash of the codeVerifier
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte(codeVerifier))
|
||||||
|
codeVerifierHash := h.Sum(nil)
|
||||||
|
|
||||||
|
// Base64 URL encode the verifier hash
|
||||||
|
encodedVerifierHash := base64.RawURLEncoding.EncodeToString(codeVerifierHash)
|
||||||
|
|
||||||
|
return encodedVerifierHash == codeChallenge
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackURL string, err error) {
|
||||||
if inputCallbackURL == "" {
|
if inputCallbackURL == "" {
|
||||||
return client.CallbackURLs[0], nil
|
return client.CallbackURLs[0], nil
|
||||||
}
|
}
|
||||||
|
|||||||
3
backend/migrations/20241115131129_pkce.down.sql
Normal file
3
backend/migrations/20241115131129_pkce.down.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE oidc_authorization_codes DROP COLUMN code_challenge;
|
||||||
|
ALTER TABLE oidc_authorization_codes DROP COLUMN code_challenge_method_sha256;
|
||||||
|
ALTER TABLE oidc_clients DROP COLUMN is_public;
|
||||||
3
backend/migrations/20241115131129_pkce.up.sql
Normal file
3
backend/migrations/20241115131129_pkce.up.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE oidc_authorization_codes ADD COLUMN code_challenge TEXT;
|
||||||
|
ALTER TABLE oidc_authorization_codes ADD COLUMN code_challenge_method_sha256 NUMERIC;
|
||||||
|
ALTER TABLE oidc_clients ADD COLUMN is_public BOOLEAN DEFAULT FALSE;
|
||||||
@@ -3,23 +3,27 @@ import type { Paginated, PaginationRequest } 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(clientId: string, scope: string, callbackURL: string, nonce?: string) {
|
async authorize(clientId: string, scope: string, callbackURL: string, nonce?: string, codeChallenge?: string, codeChallengeMethod?: string) {
|
||||||
const res = await this.api.post('/oidc/authorize', {
|
const res = await this.api.post('/oidc/authorize', {
|
||||||
scope,
|
scope,
|
||||||
nonce,
|
nonce,
|
||||||
callbackURL,
|
callbackURL,
|
||||||
clientId
|
clientId,
|
||||||
|
codeChallenge,
|
||||||
|
codeChallengeMethod
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.data as AuthorizeResponse;
|
return res.data as AuthorizeResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
async authorizeNewClient(clientId: string, scope: string, callbackURL: string, nonce?: string) {
|
async authorizeNewClient(clientId: string, scope: string, callbackURL: string, nonce?: string, codeChallenge?: string, codeChallengeMethod?: string) {
|
||||||
const res = await this.api.post('/oidc/authorize/new-client', {
|
const res = await this.api.post('/oidc/authorize/new-client', {
|
||||||
scope,
|
scope,
|
||||||
nonce,
|
nonce,
|
||||||
callbackURL,
|
callbackURL,
|
||||||
clientId
|
clientId,
|
||||||
|
codeChallenge,
|
||||||
|
codeChallengeMethod
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.data as AuthorizeResponse;
|
return res.data as AuthorizeResponse;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type OidcClient = {
|
|||||||
logoURL: string;
|
logoURL: string;
|
||||||
callbackURLs: [string, ...string[]];
|
callbackURLs: [string, ...string[]];
|
||||||
hasLogo: boolean;
|
hasLogo: boolean;
|
||||||
|
isPublic: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
|
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
|
|||||||
nonce: url.searchParams.get('nonce') || undefined,
|
nonce: url.searchParams.get('nonce') || undefined,
|
||||||
state: url.searchParams.get('state')!,
|
state: url.searchParams.get('state')!,
|
||||||
callbackURL: url.searchParams.get('redirect_uri')!,
|
callbackURL: url.searchParams.get('redirect_uri')!,
|
||||||
client
|
client,
|
||||||
|
codeChallenge: url.searchParams.get('code_challenge')!,
|
||||||
|
codeChallengeMethod: url.searchParams.get('code_challenge_method')!
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
let authorizationRequired = false;
|
let authorizationRequired = false;
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
let { scope, nonce, client, state, callbackURL } = data;
|
let { scope, nonce, client, state, callbackURL, codeChallenge, codeChallengeMethod } = data;
|
||||||
|
|
||||||
async function authorize() {
|
async function authorize() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
await oidService
|
await oidService
|
||||||
.authorize(client!.id, scope, callbackURL, nonce)
|
.authorize(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
|
||||||
.then(async ({ code, callbackURL }) => {
|
.then(async ({ code, callbackURL }) => {
|
||||||
onSuccess(code, callbackURL);
|
onSuccess(code, callbackURL);
|
||||||
});
|
});
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
isLoading = true;
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
await oidService
|
await oidService
|
||||||
.authorizeNewClient(client!.id, scope, callbackURL, nonce)
|
.authorizeNewClient(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
|
||||||
.then(async ({ code, callbackURL }) => {
|
.then(async ({ code, callbackURL }) => {
|
||||||
onSuccess(code, callbackURL);
|
onSuccess(code, callbackURL);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,8 @@
|
|||||||
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
|
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
|
||||||
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
|
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
|
||||||
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
|
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
|
||||||
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`
|
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
|
||||||
|
PKCE: client.isPublic ? 'Enabled' : 'Disabled'
|
||||||
};
|
};
|
||||||
|
|
||||||
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
|
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
|
||||||
@@ -34,6 +35,8 @@
|
|||||||
const dataPromise = oidcService.updateClient(client.id, updatedClient);
|
const dataPromise = oidcService.updateClient(client.id, updatedClient);
|
||||||
const imagePromise = oidcService.updateClientLogo(client, updatedClient.logo);
|
const imagePromise = oidcService.updateClientLogo(client, updatedClient.logo);
|
||||||
|
|
||||||
|
client.isPublic = updatedClient.isPublic;
|
||||||
|
|
||||||
await Promise.all([dataPromise, imagePromise])
|
await Promise.all([dataPromise, imagePromise])
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success('OIDC client updated successfully');
|
toast.success('OIDC client updated successfully');
|
||||||
@@ -93,27 +96,29 @@
|
|||||||
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
|
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
|
||||||
</CopyToClipboard>
|
</CopyToClipboard>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2 mt-1 flex items-center">
|
{#if !client.isPublic}
|
||||||
<Label class="w-44">Client secret</Label>
|
<div class="mb-2 mt-1 flex items-center">
|
||||||
{#if $clientSecretStore}
|
<Label class="w-44">Client secret</Label>
|
||||||
<CopyToClipboard value={$clientSecretStore}>
|
{#if $clientSecretStore}
|
||||||
<span class="text-muted-foreground text-sm" data-testid="client-secret">
|
<CopyToClipboard value={$clientSecretStore}>
|
||||||
{$clientSecretStore}
|
<span class="text-muted-foreground text-sm" data-testid="client-secret">
|
||||||
</span>
|
{$clientSecretStore}
|
||||||
</CopyToClipboard>
|
</span>
|
||||||
{:else}
|
</CopyToClipboard>
|
||||||
<span class="text-muted-foreground text-sm" data-testid="client-secret"
|
{:else}
|
||||||
>••••••••••••••••••••••••••••••••</span
|
<span class="text-muted-foreground text-sm" data-testid="client-secret"
|
||||||
>
|
>••••••••••••••••••••••••••••••••</span
|
||||||
<Button
|
>
|
||||||
class="ml-2"
|
<Button
|
||||||
onclick={createClientSecret}
|
class="ml-2"
|
||||||
size="sm"
|
onclick={createClientSecret}
|
||||||
variant="ghost"
|
size="sm"
|
||||||
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
|
variant="ghost"
|
||||||
>
|
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
|
||||||
{/if}
|
>
|
||||||
</div>
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if showAllDetails}
|
{#if showAllDetails}
|
||||||
<div transition:slide>
|
<div transition:slide>
|
||||||
{#each Object.entries(setupDetails) as [key, value]}
|
{#each Object.entries(setupDetails) as [key, value]}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import FileInput from '$lib/components/file-input.svelte';
|
import FileInput from '$lib/components/file-input.svelte';
|
||||||
import FormInput from '$lib/components/form-input.svelte';
|
import FormInput from '$lib/components/form-input.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import Label from '$lib/components/ui/label/label.svelte';
|
import Label from '$lib/components/ui/label/label.svelte';
|
||||||
import type {
|
import type {
|
||||||
OidcClient,
|
OidcClient,
|
||||||
@@ -28,12 +29,14 @@
|
|||||||
|
|
||||||
const client: OidcClientCreate = {
|
const client: OidcClientCreate = {
|
||||||
name: existingClient?.name || '',
|
name: existingClient?.name || '',
|
||||||
callbackURLs: existingClient?.callbackURLs || [""]
|
callbackURLs: existingClient?.callbackURLs || [''],
|
||||||
|
isPublic: existingClient?.isPublic || false
|
||||||
};
|
};
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().min(2).max(50),
|
name: z.string().min(2).max(50),
|
||||||
callbackURLs: z.array(z.string().url()).nonempty()
|
callbackURLs: z.array(z.string().url()).nonempty(),
|
||||||
|
isPublic: z.boolean()
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormSchema = typeof formSchema;
|
type FormSchema = typeof formSchema;
|
||||||
@@ -71,15 +74,27 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form onsubmit={onSubmit}>
|
<form onsubmit={onSubmit}>
|
||||||
<div class="flex flex-col gap-3 sm:flex-row">
|
<div class="grid grid-cols-2 gap-3 sm:flex-row">
|
||||||
<FormInput label="Name" class="w-full" bind:input={$inputs.name} />
|
<FormInput label="Name" class="w-full" bind:input={$inputs.name} />
|
||||||
<OidcCallbackUrlInput
|
<OidcCallbackUrlInput
|
||||||
class="w-full"
|
class="w-full"
|
||||||
bind:callbackURLs={$inputs.callbackURLs.value}
|
bind:callbackURLs={$inputs.callbackURLs.value}
|
||||||
bind:error={$inputs.callbackURLs.error}
|
bind:error={$inputs.callbackURLs.error}
|
||||||
/>
|
/>
|
||||||
|
<div class="items-top flex space-x-2">
|
||||||
|
<Checkbox id="admin-privileges" bind:checked={$inputs.isPublic.value} />
|
||||||
|
<div class="grid gap-1.5 leading-none">
|
||||||
|
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
|
||||||
|
Public Client
|
||||||
|
</Label>
|
||||||
|
<p class="text-muted-foreground text-[0.8rem]">
|
||||||
|
Public clients do not have a client secret and use PKCE instead. Enable this if your
|
||||||
|
client is a SPA or mobile app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3">
|
<div class="mt-8">
|
||||||
<Label for="logo">Logo</Label>
|
<Label for="logo">Logo</Label>
|
||||||
<div class="mt-2 flex items-end gap-3">
|
<div class="mt-2 flex items-end gap-3">
|
||||||
{#if logoDataURL}
|
{#if logoDataURL}
|
||||||
|
|||||||
Reference in New Issue
Block a user