mirror of
https://github.com/nikdoof/pocket-id.git
synced 2025-12-14 07:12:19 +00:00
feat: add support for multiple callback urls
This commit is contained in:
@@ -6,7 +6,6 @@ var (
|
|||||||
ErrUsernameTaken = errors.New("username is already taken")
|
ErrUsernameTaken = errors.New("username is already taken")
|
||||||
ErrEmailTaken = errors.New("email is already taken")
|
ErrEmailTaken = errors.New("email is already taken")
|
||||||
ErrSetupAlreadyCompleted = errors.New("setup already completed")
|
ErrSetupAlreadyCompleted = errors.New("setup already completed")
|
||||||
ErrInvalidBody = errors.New("invalid request body")
|
|
||||||
ErrTokenInvalidOrExpired = errors.New("token is invalid or expired")
|
ErrTokenInvalidOrExpired = errors.New("token is invalid or expired")
|
||||||
ErrOidcMissingAuthorization = errors.New("missing authorization")
|
ErrOidcMissingAuthorization = errors.New("missing authorization")
|
||||||
ErrOidcGrantTypeNotSupported = errors.New("grant type not supported")
|
ErrOidcGrantTypeNotSupported = errors.New("grant type not supported")
|
||||||
|
|||||||
@@ -40,39 +40,55 @@ type OidcController struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
||||||
var input dto.AuthorizeOidcClientDto
|
var input dto.AuthorizeOidcClientRequestDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
utils.ControllerError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
code, err := oc.oidcService.Authorize(input, c.GetString("userID"))
|
code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrOidcMissingAuthorization) {
|
if errors.Is(err, common.ErrOidcMissingAuthorization) {
|
||||||
utils.CustomControllerError(c, http.StatusForbidden, err.Error())
|
utils.CustomControllerError(c, http.StatusForbidden, err.Error())
|
||||||
|
} else if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
|
||||||
|
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
||||||
} else {
|
} else {
|
||||||
utils.ControllerError(c, err)
|
utils.ControllerError(c, err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"code": code})
|
response := dto.AuthorizeOidcClientResponseDto{
|
||||||
|
Code: code,
|
||||||
|
CallbackURL: callbackURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
|
func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
|
||||||
var input dto.AuthorizeOidcClientDto
|
var input dto.AuthorizeOidcClientRequestDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
utils.ControllerError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
code, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"))
|
code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
|
||||||
|
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
||||||
|
} else {
|
||||||
|
utils.ControllerError(c, err)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"code": code})
|
response := dto.AuthorizeOidcClientResponseDto{
|
||||||
|
Code: code,
|
||||||
|
CallbackURL: callbackURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (oc *OidcController) createIDTokenHandler(c *gin.Context) {
|
func (oc *OidcController) createIDTokenHandler(c *gin.Context) {
|
||||||
|
|||||||
@@ -17,10 +17,16 @@ type OidcClientCreateDto struct {
|
|||||||
CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"`
|
CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizeOidcClientDto 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"`
|
||||||
Nonce string `json:"nonce"`
|
CallbackURL string `json:"callbackURL"`
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthorizeOidcClientResponseDto struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
CallbackURL string `json:"callbackURL"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcIdTokenDto struct {
|
type OidcIdTokenDto struct {
|
||||||
|
|||||||
@@ -52,17 +52,14 @@ func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
|
|||||||
|
|
||||||
type CallbackURLs []string
|
type CallbackURLs []string
|
||||||
|
|
||||||
func (s *CallbackURLs) Scan(value interface{}) error {
|
func (cu *CallbackURLs) Scan(value interface{}) error {
|
||||||
switch v := value.(type) {
|
if v, ok := value.([]byte); ok {
|
||||||
case []byte:
|
return json.Unmarshal(v, cu)
|
||||||
return json.Unmarshal(v, s)
|
} else {
|
||||||
case string:
|
return errors.New("type assertion to []byte failed")
|
||||||
return json.Unmarshal([]byte(v), s)
|
|
||||||
default:
|
|
||||||
return errors.New("type assertion to []byte or string failed")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (atl CallbackURLs) Value() (driver.Value, error) {
|
func (cu CallbackURLs) Value() (driver.Value, error) {
|
||||||
return json.Marshal(atl)
|
return json.Marshal(cu)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -27,33 +28,50 @@ func NewOidcService(db *gorm.DB, jwtService *JwtService) *OidcService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OidcService) Authorize(req dto.AuthorizeOidcClientDto, userID string) (string, error) {
|
func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID string) (string, string, error) {
|
||||||
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
|
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
|
||||||
s.db.First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", req.ClientID, userID)
|
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
|
||||||
|
|
||||||
if userAuthorizedOIDCClient.Scope != req.Scope {
|
if userAuthorizedOIDCClient.Scope != input.Scope {
|
||||||
return "", common.ErrOidcMissingAuthorization
|
return "", "", common.ErrOidcMissingAuthorization
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.createAuthorizationCode(req.ClientID, userID, req.Scope, req.Nonce)
|
callbackURL, err := getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
|
||||||
|
return code, callbackURL, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OidcService) AuthorizeNewClient(req dto.AuthorizeOidcClientDto, userID string) (string, error) {
|
func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto, userID string) (string, string, error) {
|
||||||
|
var client model.OidcClient
|
||||||
|
if err := s.db.First(&client, "id = ?", input.ClientID).Error; err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
callbackURL, err := getCallbackURL(client, input.CallbackURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
userAuthorizedClient := model.UserAuthorizedOidcClient{
|
userAuthorizedClient := model.UserAuthorizedOidcClient{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ClientID: req.ClientID,
|
ClientID: input.ClientID,
|
||||||
Scope: req.Scope,
|
Scope: input.Scope,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.Create(&userAuthorizedClient).Error; err != nil {
|
if err := s.db.Create(&userAuthorizedClient).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||||
err = s.db.Model(&userAuthorizedClient).Update("scope", req.Scope).Error
|
err = s.db.Model(&userAuthorizedClient).Update("scope", input.Scope).Error
|
||||||
} else {
|
} else {
|
||||||
return "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.createAuthorizationCode(req.ClientID, userID, req.Scope, req.Nonce)
|
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
|
||||||
|
return code, callbackURL, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) {
|
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) {
|
||||||
@@ -321,3 +339,14 @@ 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) {
|
||||||
|
if inputCallbackURL == "" {
|
||||||
|
return client.CallbackURLs[0], nil
|
||||||
|
}
|
||||||
|
if slices.Contains(client.CallbackURLs, inputCallbackURL) {
|
||||||
|
return inputCallbackURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", common.ErrOidcInvalidCallbackURL
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ CREATE TABLE webauthn_credentials
|
|||||||
credential_id TEXT NOT NULL UNIQUE,
|
credential_id TEXT NOT NULL UNIQUE,
|
||||||
public_key BLOB NOT NULL,
|
public_key BLOB NOT NULL,
|
||||||
attestation_type TEXT NOT NULL,
|
attestation_type TEXT NOT NULL,
|
||||||
transport TEXT NOT NULL,
|
transport BLOB NOT NULL,
|
||||||
user_id TEXT REFERENCES users
|
user_id TEXT REFERENCES users
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
create table oidc_clients
|
||||||
|
(
|
||||||
|
id TEXT not null
|
||||||
|
primary key,
|
||||||
|
created_at DATETIME,
|
||||||
|
name TEXT,
|
||||||
|
secret TEXT,
|
||||||
|
callback_url TEXT,
|
||||||
|
image_type TEXT,
|
||||||
|
created_by_id TEXT
|
||||||
|
references users
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into oidc_clients(id, created_at, name, secret, callback_url, image_type, created_by_id)
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
created_at,
|
||||||
|
name,
|
||||||
|
secret,
|
||||||
|
json_extract(callback_urls, '$[0]'),
|
||||||
|
image_type,
|
||||||
|
created_by_id
|
||||||
|
from oidc_clients_dg_tmp;
|
||||||
|
|
||||||
|
drop table oidc_clients_dg_tmp;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
create table oidc_clients_dg_tmp
|
||||||
|
(
|
||||||
|
id TEXT not null
|
||||||
|
primary key,
|
||||||
|
created_at DATETIME,
|
||||||
|
name TEXT,
|
||||||
|
secret TEXT,
|
||||||
|
callback_urls BLOB,
|
||||||
|
image_type TEXT,
|
||||||
|
created_by_id TEXT
|
||||||
|
references users
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into oidc_clients_dg_tmp(id, created_at, name, secret, callback_urls, image_type, created_by_id)
|
||||||
|
select id,
|
||||||
|
created_at,
|
||||||
|
name,
|
||||||
|
secret,
|
||||||
|
CAST(json_group_array(json_quote(callback_url)) AS BLOB),
|
||||||
|
image_type,
|
||||||
|
created_by_id
|
||||||
|
from oidc_clients;
|
||||||
|
|
||||||
|
drop table oidc_clients;
|
||||||
|
|
||||||
|
alter table oidc_clients_dg_tmp
|
||||||
|
rename to oidc_clients;
|
||||||
@@ -2,15 +2,17 @@
|
|||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import type { FormInput } from '$lib/utils/form-util';
|
import type { FormInput } from '$lib/utils/form-util';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { Input } from './ui/input';
|
import { Input } from './ui/input';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
input = $bindable(),
|
input = $bindable(),
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
children
|
children,
|
||||||
}: {
|
...restProps
|
||||||
input: FormInput<string | boolean | number>;
|
}: HTMLAttributes<HTMLDivElement> & {
|
||||||
|
input?: FormInput<string | boolean | number>;
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
@@ -19,19 +21,19 @@
|
|||||||
const id = label.toLowerCase().replace(/ /g, '-');
|
const id = label.toLowerCase().replace(/ /g, '-');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div {...restProps}>
|
||||||
<Label class="mb-0" for={id}>{label}</Label>
|
<Label class="mb-0" for={id}>{label}</Label>
|
||||||
{#if description}
|
{#if description}
|
||||||
<p class="text-muted-foreground text-xs mt-1">{description}</p>
|
<p class="text-muted-foreground mt-1 text-xs">{description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
{#if children}
|
{#if children}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{:else}
|
{:else if input}
|
||||||
<Input {id} bind:value={input.value} />
|
<Input {id} bind:value={input.value} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if input.error}
|
{#if input?.error}
|
||||||
<p class="text-sm text-red-500">{input.error}</p>
|
<p class="mt-1 text-sm text-red-500">{input.error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
import type { OidcClient, OidcClientCreate } from '$lib/types/oidc.type';
|
import type { AuthorizeResponse, OidcClient, OidcClientCreate } from '$lib/types/oidc.type';
|
||||||
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
|
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, nonce?: string) {
|
async authorize(clientId: string, scope: string, callbackURL : string, nonce?: string) {
|
||||||
const res = await this.api.post('/oidc/authorize', {
|
const res = await this.api.post('/oidc/authorize', {
|
||||||
scope,
|
scope,
|
||||||
nonce,
|
nonce,
|
||||||
|
callbackURL,
|
||||||
clientId
|
clientId
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.data.code as string;
|
return res.data as AuthorizeResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
async authorizeNewClient(clientId: string, scope: string, nonce?: string) {
|
async authorizeNewClient(clientId: string, scope: string, callbackURL: string, nonce?: 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,
|
||||||
clientId
|
clientId
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.data.code as string;
|
return res.data as AuthorizeResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listClients(search?: string, pagination?: PaginationRequest) {
|
async listClients(search?: string, pagination?: PaginationRequest) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export type OidcClient = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
logoURL: string;
|
logoURL: string;
|
||||||
callbackURL: string;
|
callbackURLs: [string, ...string[]];
|
||||||
hasLogo: boolean;
|
hasLogo: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -11,3 +11,8 @@ export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
|
|||||||
export type OidcClientCreateWithLogo = OidcClientCreate & {
|
export type OidcClientCreateWithLogo = OidcClientCreate & {
|
||||||
logo: File | null;
|
logo: File | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AuthorizeResponse = {
|
||||||
|
code: string;
|
||||||
|
callbackURL: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
|
|||||||
scope: url.searchParams.get('scope')!,
|
scope: url.searchParams.get('scope')!,
|
||||||
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')!,
|
||||||
client
|
client
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
let authorizationRequired = false;
|
let authorizationRequired = false;
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
let { scope, nonce, client, state } = data;
|
let { scope, nonce, client, state, callbackURL } = data;
|
||||||
|
|
||||||
async function authorize() {
|
async function authorize() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
@@ -36,9 +36,11 @@
|
|||||||
await webauthnService.finishLogin(authResponse);
|
await webauthnService.finishLogin(authResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
await oidService.authorize(client!.id, scope, nonce).then(async (code) => {
|
await oidService
|
||||||
onSuccess(code);
|
.authorize(client!.id, scope, callbackURL, nonce)
|
||||||
});
|
.then(async ({ code, callbackURL }) => {
|
||||||
|
onSuccess(code, callbackURL);
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof AxiosError && e.response?.status === 403) {
|
if (e instanceof AxiosError && e.response?.status === 403) {
|
||||||
authorizationRequired = true;
|
authorizationRequired = true;
|
||||||
@@ -52,19 +54,21 @@
|
|||||||
async function authorizeNewClient() {
|
async function authorizeNewClient() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
await oidService.authorizeNewClient(client!.id, scope, nonce).then(async (code) => {
|
await oidService
|
||||||
onSuccess(code);
|
.authorizeNewClient(client!.id, scope, callbackURL, nonce)
|
||||||
});
|
.then(async ({ code, callbackURL }) => {
|
||||||
|
onSuccess(code, callbackURL);
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMessage = getWebauthnErrorMessage(e);
|
errorMessage = getWebauthnErrorMessage(e);
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSuccess(code: string) {
|
function onSuccess(code: string, callbackURL: string) {
|
||||||
success = true;
|
success = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = `${client!.callbackURL}?code=${code}&state=${state}`;
|
window.location.href = `${callbackURL}?code=${code}&state=${state}`;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -47,7 +47,6 @@
|
|||||||
<form onsubmit={onSubmit}>
|
<form onsubmit={onSubmit}>
|
||||||
<div class="flex flex-col gap-5">
|
<div class="flex flex-col gap-5">
|
||||||
<FormInput label="Application Name" bind:input={$inputs.appName} />
|
<FormInput label="Application Name" bind:input={$inputs.appName} />
|
||||||
|
|
||||||
<FormInput
|
<FormInput
|
||||||
label="Session Duration"
|
label="Session Duration"
|
||||||
description="The duration of a session in minutes before the user has to sign in again."
|
description="The duration of a session in minutes before the user has to sign in again."
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import FormInput from '$lib/components/form-input.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { LucideMinus, LucidePlus } from 'lucide-svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
callbackURLs = $bindable(),
|
||||||
|
error = $bindable(null),
|
||||||
|
...restProps
|
||||||
|
}: HTMLAttributes<HTMLDivElement> & {
|
||||||
|
callbackURLs: string[];
|
||||||
|
error?: string | null;
|
||||||
|
children?: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const limit = 5;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div {...restProps}>
|
||||||
|
<FormInput label="Callback URLs">
|
||||||
|
<div class="flex flex-col gap-y-2">
|
||||||
|
{#each callbackURLs as _, i}
|
||||||
|
<div class="flex gap-x-2">
|
||||||
|
<Input data-testid={`callback-url-${i + 1}`} bind:value={callbackURLs[i]} />
|
||||||
|
{#if callbackURLs.length > 1}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
on:click={() => callbackURLs = callbackURLs.filter((_, index) => index !== i)}
|
||||||
|
>
|
||||||
|
<LucideMinus class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</FormInput>
|
||||||
|
{#if error}
|
||||||
|
<p class="mt-1 text-sm text-red-500">{error}</p>
|
||||||
|
{/if}
|
||||||
|
{#if callbackURLs.length < limit}
|
||||||
|
<Button
|
||||||
|
class="mt-2"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
on:click={() => callbackURLs = [...callbackURLs, '']}
|
||||||
|
>
|
||||||
|
<LucidePlus class="mr-1 h-4 w-4" />
|
||||||
|
Add another
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
} from '$lib/types/oidc.type';
|
} from '$lib/types/oidc.type';
|
||||||
import { createForm } from '$lib/utils/form-util';
|
import { createForm } from '$lib/utils/form-util';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
callback,
|
callback,
|
||||||
@@ -27,12 +28,12 @@
|
|||||||
|
|
||||||
const client: OidcClientCreate = {
|
const client: OidcClientCreate = {
|
||||||
name: existingClient?.name || '',
|
name: existingClient?.name || '',
|
||||||
callbackURL: existingClient?.callbackURL || ''
|
callbackURLs: existingClient?.callbackURLs || [""]
|
||||||
};
|
};
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().min(2).max(50),
|
name: z.string().min(2).max(50),
|
||||||
callbackURL: z.string().url()
|
callbackURLs: z.array(z.string().url()).nonempty()
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormSchema = typeof formSchema;
|
type FormSchema = typeof formSchema;
|
||||||
@@ -70,32 +71,40 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form onsubmit={onSubmit}>
|
<form onsubmit={onSubmit}>
|
||||||
<div class="mt-3 grid grid-cols-2 gap-3">
|
<div class="flex flex-col gap-3 sm:flex-row">
|
||||||
<FormInput label="Name" bind:input={$inputs.name} />
|
<FormInput label="Name" class="w-full" bind:input={$inputs.name} />
|
||||||
<FormInput label="Callback URL" bind:input={$inputs.callbackURL} />
|
<OidcCallbackUrlInput
|
||||||
<div class="mt-3">
|
class="w-full"
|
||||||
<Label for="logo">Logo</Label>
|
bind:callbackURLs={$inputs.callbackURLs.value}
|
||||||
<div class="mt-2 flex items-end gap-3">
|
bind:error={$inputs.callbackURLs.error}
|
||||||
{#if logoDataURL}
|
/>
|
||||||
<div class="h-32 w-32 rounded-2xl bg-muted p-3">
|
</div>
|
||||||
<img class="m-auto max-h-full max-w-full object-contain" src={logoDataURL} alt={`${$inputs.name.value} logo`} />
|
<div class="mt-3">
|
||||||
</div>
|
<Label for="logo">Logo</Label>
|
||||||
{/if}
|
<div class="mt-2 flex items-end gap-3">
|
||||||
<div class="flex flex-col gap-2">
|
{#if logoDataURL}
|
||||||
<FileInput
|
<div class="bg-muted h-32 w-32 rounded-2xl p-3">
|
||||||
id="logo"
|
<img
|
||||||
variant="secondary"
|
class="m-auto max-h-full max-w-full object-contain"
|
||||||
accept="image/png, image/jpeg, image/svg+xml"
|
src={logoDataURL}
|
||||||
onchange={onLogoChange}
|
alt={`${$inputs.name.value} logo`}
|
||||||
>
|
/>
|
||||||
<Button variant="secondary">
|
|
||||||
{existingClient?.hasLogo ? 'Change Logo' : 'Upload Logo'}
|
|
||||||
</Button>
|
|
||||||
</FileInput>
|
|
||||||
{#if logoDataURL}
|
|
||||||
<Button variant="outline" on:click={resetLogo}>Remove Logo</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<FileInput
|
||||||
|
id="logo"
|
||||||
|
variant="secondary"
|
||||||
|
accept="image/png, image/jpeg, image/svg+xml"
|
||||||
|
onchange={onLogoChange}
|
||||||
|
>
|
||||||
|
<Button variant="secondary">
|
||||||
|
{existingClient?.hasLogo ? 'Change Logo' : 'Upload Logo'}
|
||||||
|
</Button>
|
||||||
|
</FileInput>
|
||||||
|
{#if logoDataURL}
|
||||||
|
<Button variant="outline" on:click={resetLogo}>Remove Logo</Button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,9 +26,13 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
firstName: z.string().min(2).max(50),
|
firstName: z.string().min(2).max(30),
|
||||||
lastName: z.string().min(2).max(50),
|
lastName: z.string().min(2).max(30),
|
||||||
username: z.string().min(2).max(50),
|
username: z
|
||||||
|
.string()
|
||||||
|
.min(2)
|
||||||
|
.max(30)
|
||||||
|
.regex(/^[a-z0-9_]+$/, 'Only lowercase letters, numbers, and underscores are allowed'),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
isAdmin: z.boolean()
|
isAdmin: z.boolean()
|
||||||
});
|
});
|
||||||
@@ -66,10 +70,10 @@
|
|||||||
<div class="items-top mt-5 flex space-x-2">
|
<div class="items-top mt-5 flex space-x-2">
|
||||||
<Checkbox id="admin-privileges" bind:checked={$inputs.isAdmin.value} />
|
<Checkbox id="admin-privileges" bind:checked={$inputs.isAdmin.value} />
|
||||||
<div class="grid gap-1.5 leading-none">
|
<div class="grid gap-1.5 leading-none">
|
||||||
<Label for="admin-privileges" class="text-sm font-medium leading-none mb-0">
|
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
|
||||||
Admin Privileges
|
Admin Privileges
|
||||||
</Label>
|
</Label>
|
||||||
<p class="text-[0.8rem] text-muted-foreground">Admins have full access to the admin panel.</p>
|
<p class="text-muted-foreground text-[0.8rem]">Admins have full access to the admin panel.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 flex justify-end">
|
<div class="mt-5 flex justify-end">
|
||||||
|
|||||||
@@ -23,17 +23,18 @@ export const users = {
|
|||||||
|
|
||||||
export const oidcClients = {
|
export const oidcClients = {
|
||||||
nextcloud: {
|
nextcloud: {
|
||||||
id: "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
id: '3654a746-35d4-4321-ac61-0bdcff2b4055',
|
||||||
name: 'Nextcloud',
|
name: 'Nextcloud',
|
||||||
callbackUrl: 'http://nextcloud/auth/callback'
|
callbackUrl: 'http://nextcloud/auth/callback'
|
||||||
},
|
},
|
||||||
immich: {
|
immich: {
|
||||||
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'
|
||||||
},
|
},
|
||||||
pingvinShare: {
|
pingvinShare: {
|
||||||
name: 'Pingvin Share',
|
name: 'Pingvin Share',
|
||||||
callbackUrl: 'http://pingvin.share/auth/callback'
|
callbackUrl: 'http://pingvin.share/auth/callback',
|
||||||
|
secondCallbackUrl: 'http://pingvin.share/auth/callback2'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ test('Create OIDC client', async ({ page }) => {
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'Add OIDC Client' }).click();
|
await page.getByRole('button', { name: 'Add OIDC Client' }).click();
|
||||||
await page.getByLabel('Name').fill(oidcClient.name);
|
await page.getByLabel('Name').fill(oidcClient.name);
|
||||||
await page.getByLabel('Callback URL').fill(oidcClient.callbackUrl);
|
|
||||||
|
await page.getByTestId('callback-url-1').fill(oidcClient.callbackUrl);
|
||||||
|
await page.getByRole('button', { name: 'Add another' }).click();
|
||||||
|
await page.getByTestId('callback-url-2').fill(oidcClient.secondCallbackUrl!);
|
||||||
|
|
||||||
await page.getByLabel('logo').setInputFiles('tests/assets/pingvin-share-logo.png');
|
await page.getByLabel('logo').setInputFiles('tests/assets/pingvin-share-logo.png');
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
@@ -20,7 +24,8 @@ test('Create OIDC client', async ({ page }) => {
|
|||||||
expect(clientId?.length).toBe(36);
|
expect(clientId?.length).toBe(36);
|
||||||
expect((await page.getByTestId('client-secret').textContent())?.length).toBe(32);
|
expect((await page.getByTestId('client-secret').textContent())?.length).toBe(32);
|
||||||
await expect(page.getByLabel('Name')).toHaveValue(oidcClient.name);
|
await expect(page.getByLabel('Name')).toHaveValue(oidcClient.name);
|
||||||
await expect(page.getByLabel('Callback URL')).toHaveValue(oidcClient.callbackUrl);
|
await expect(page.getByTestId('callback-url-1')).toHaveValue(oidcClient.callbackUrl);
|
||||||
|
await expect(page.getByTestId('callback-url-2')).toHaveValue(oidcClient.secondCallbackUrl!);
|
||||||
await expect(page.getByRole('img', { name: `${oidcClient.name} logo` })).toBeVisible();
|
await expect(page.getByRole('img', { name: `${oidcClient.name} logo` })).toBeVisible();
|
||||||
await page.request
|
await page.request
|
||||||
.get(`/api/oidc/clients/${clientId}/logo`)
|
.get(`/api/oidc/clients/${clientId}/logo`)
|
||||||
@@ -32,7 +37,7 @@ test('Edit OIDC client', async ({ page }) => {
|
|||||||
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
|
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
|
||||||
|
|
||||||
await page.getByLabel('Name').fill('Nextcloud updated');
|
await page.getByLabel('Name').fill('Nextcloud updated');
|
||||||
await page.getByLabel('Callback URL').fill('http://nextcloud-updated/auth/callback');
|
await page.getByTestId('callback-url-1').fill('http://nextcloud-updated/auth/callback');
|
||||||
await page.getByLabel('logo').setInputFiles('tests/assets/nextcloud-logo.png');
|
await page.getByLabel('logo').setInputFiles('tests/assets/nextcloud-logo.png');
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user