Add device flow
This commit is contained in:
parent
77a4aa1e4e
commit
cfa8ff7048
1
migrations/0003_round_killmonger.sql
Normal file
1
migrations/0003_round_killmonger.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `o_auth2_token` MODIFY COLUMN `type` enum('code','device_code','access_token','refresh_token') NOT NULL;
|
1064
migrations/meta/0003_snapshot.json
Normal file
1064
migrations/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -22,6 +22,13 @@
|
|||||||
"when": 1717344670405,
|
"when": 1717344670405,
|
||||||
"tag": "0002_whole_vivisector",
|
"tag": "0002_whole_vivisector",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1717843139528,
|
||||||
|
"tag": "0003_round_killmonger",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
90
src/lib/components/oauth2/OAuth2AuthorizeCard.svelte
Normal file
90
src/lib/components/oauth2/OAuth2AuthorizeCard.svelte
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import type { OAuth2ClientInfo, UserSession } from '$lib/types';
|
||||||
|
import AvatarCard from '../avatar/AvatarCard.svelte';
|
||||||
|
|
||||||
|
export let user: UserSession;
|
||||||
|
export let client: OAuth2ClientInfo;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="user-client-wrapper">
|
||||||
|
{#if user}
|
||||||
|
<div class="card">
|
||||||
|
<AvatarCard src={`/api/avatar/${user.uuid}`} alt={user.name}>
|
||||||
|
<div class="card-inner">
|
||||||
|
<span class="card-display-name">{user.name}</span>
|
||||||
|
<span class="card-user-name">@{user.username}</span>
|
||||||
|
</div>
|
||||||
|
</AvatarCard>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="graphic" aria-hidden="true"></div>
|
||||||
|
<div class="card">
|
||||||
|
<AvatarCard src={`/api/avatar/client/${client.client_id}`} alt={client.title}>
|
||||||
|
<div class="card-inner">
|
||||||
|
<span class="card-display-name">{client.title}</span>
|
||||||
|
<span class="card-user-name">{client.description}</span>
|
||||||
|
|
||||||
|
<div class="card-links">
|
||||||
|
{#each client.links as link}
|
||||||
|
<a href={link.url} target="_blank" rel="nofollow noreferrer"
|
||||||
|
>{$t(`oauth2.authorize.link.${link.type}`)}</a
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AvatarCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.user-client-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
|
||||||
|
& .graphic {
|
||||||
|
background-image: url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 style=%27width:24px;height:24px%27 viewBox=%270 0 24 24%27%3E%3Cpath fill=%27currentColor%27 d=%27M10,4H14V13L17.5,9.5L19.92,11.92L12,19.84L4.08,11.92L6.5,9.5L10,13V4Z%27 /%3E%3C/svg%3E');
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
opacity: 0.4;
|
||||||
|
margin: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .card {
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
& .graphic {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .card {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.card-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.card-display-name {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.card-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
:global(html[theme-base='dark']) .user-client-wrapper .graphic {
|
||||||
|
filter: invert();
|
||||||
|
}
|
||||||
|
</style>
|
66
src/lib/components/oauth2/OAuth2ScopesCard.svelte
Normal file
66
src/lib/components/oauth2/OAuth2ScopesCard.svelte
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import type { OAuth2ClientInfo } from '$lib/types';
|
||||||
|
|
||||||
|
export let client: OAuth2ClientInfo;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="scope">
|
||||||
|
{#if client.allowedScopes?.length}
|
||||||
|
<h3>{$t('oauth2.authorize.allowed')}</h3>
|
||||||
|
<div class="scope-list scope-list-allowed">
|
||||||
|
{#each client.allowedScopes as scope}
|
||||||
|
<span class="scope-list-item">{$t(`oauth2.authorize.scope.${scope}`)}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if client.disallowedScopes?.length}
|
||||||
|
<h3>{$t('oauth2.authorize.disallowed')}</h3>
|
||||||
|
<div class="scope-list scope-list-disallowed">
|
||||||
|
{#each client.disallowedScopes as scope}
|
||||||
|
<span class="scope-list-item">{$t(`oauth2.authorize.scope.${scope}`)}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scope {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-list-allowed {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
& .scope-list-item::before {
|
||||||
|
content: url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 style=%27width:24px;height:24px%27 viewBox=%270 0 24 24%27%3E%3Cpath fill=%27%2300f000%27 d=%27M9,20.42L2.79,14.21L5.62,11.38L9,14.77L18.88,4.88L21.71,7.71L9,20.42Z%27 /%3E%3C/svg%3E');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-list-disallowed .scope-list-item::before {
|
||||||
|
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' style='width:24px;height:24px' viewBox='0 0 24 24'%3E%3Cpath fill='%23f00000' d='M20 6.91L17.09 4L12 9.09L6.91 4L4 6.91L9.09 12L4 17.09L6.91 20L12 14.91L17.09 20L20 17.09L14.91 12L20 6.91Z' /%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
display: block;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
margin: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -43,6 +43,7 @@
|
|||||||
"scopesHint": "The level of access to information you will be needing for this application.",
|
"scopesHint": "The level of access to information you will be needing for this application.",
|
||||||
"grants": "Available grant types",
|
"grants": "Available grant types",
|
||||||
"grantsHint": "The OAuth2 authorization flows you will be using with this application.",
|
"grantsHint": "The OAuth2 authorization flows you will be using with this application.",
|
||||||
|
"grantsWarning": "Please note that id_token, implicit and device_code grant types are less secure than other flows, as they do not require a server to authenticate this application with its secret and there is potential for impersonation. You might want to make a separate application for using these flows and give them access to less information.",
|
||||||
"created": "Created at",
|
"created": "Created at",
|
||||||
"owner": "Created by",
|
"owner": "Created by",
|
||||||
"ownerMe": "that's you!",
|
"ownerMe": "that's you!",
|
||||||
@ -88,12 +89,14 @@
|
|||||||
"token": "OAuth2 Token endpoint",
|
"token": "OAuth2 Token endpoint",
|
||||||
"introspect": "OAuth2 Introspection endpoint",
|
"introspect": "OAuth2 Introspection endpoint",
|
||||||
"userinfo": "User information endpoint (Bearer)",
|
"userinfo": "User information endpoint (Bearer)",
|
||||||
|
"device": "OAuth2 Device Authorization endpoint",
|
||||||
"openid": "OpenID Connect configuration"
|
"openid": "OpenID Connect configuration"
|
||||||
},
|
},
|
||||||
"grantTexts": {
|
"grantTexts": {
|
||||||
"authorization_code": "Authorization code",
|
"authorization_code": "Authorization code",
|
||||||
"client_credentials": "Client credentials",
|
"client_credentials": "Client credentials",
|
||||||
"refresh_token": "Refresh token",
|
"refresh_token": "Refresh token",
|
||||||
|
"device_code": "Device authorization",
|
||||||
"implicit": "Implicit token",
|
"implicit": "Implicit token",
|
||||||
"id_token": "ID token (OpenID Connect)"
|
"id_token": "ID token (OpenID Connect)"
|
||||||
},
|
},
|
||||||
@ -102,7 +105,7 @@
|
|||||||
"profile": "Basic profile information",
|
"profile": "Basic profile information",
|
||||||
"email": "Access user email address",
|
"email": "Access user email address",
|
||||||
"privileges": "Access user privilege list",
|
"privileges": "Access user privilege list",
|
||||||
"management": "Manage your application",
|
"management": "Manage your application (with Client credientials only)",
|
||||||
"account": "Change user account settings",
|
"account": "Change user account settings",
|
||||||
"openid": "Get an ID token JWT (OpenID Connect)"
|
"openid": "Get an ID token JWT (OpenID Connect)"
|
||||||
},
|
},
|
||||||
|
@ -18,5 +18,15 @@
|
|||||||
"privacy": "Privacy policy",
|
"privacy": "Privacy policy",
|
||||||
"terms": "Terms of Service"
|
"terms": "Terms of Service"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"device": {
|
||||||
|
"title": "Authorize device",
|
||||||
|
"description": "Please enter the code displayed on your device to proceed.",
|
||||||
|
"deviceCode": "Device code",
|
||||||
|
"success": "Success! Please check back to your device for further instructions. You may now close this window."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"noCode": "Please enter a code.",
|
||||||
|
"invalidCode": "The provided code is invalid or it has expired"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -121,7 +121,7 @@ export type NewOAuth2ClientUrl = typeof oauth2ClientUrl.$inferInsert;
|
|||||||
|
|
||||||
export const oauth2Token = mysqlTable('o_auth2_token', {
|
export const oauth2Token = mysqlTable('o_auth2_token', {
|
||||||
id: int('id').autoincrement().notNull(),
|
id: int('id').autoincrement().notNull(),
|
||||||
type: mysqlEnum('type', ['code', 'access_token', 'refresh_token']).notNull(),
|
type: mysqlEnum('type', ['code', 'device_code', 'access_token', 'refresh_token']).notNull(),
|
||||||
token: text('token').notNull(),
|
token: text('token').notNull(),
|
||||||
scope: text('scope'),
|
scope: text('scope'),
|
||||||
expires_at: timestamp('expires_at', { mode: 'date' })
|
expires_at: timestamp('expires_at', { mode: 'date' })
|
||||||
|
73
src/lib/server/oauth2/controller/device-authorization.ts
Normal file
73
src/lib/server/oauth2/controller/device-authorization.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
import { ApiUtils } from '$lib/server/api-utils';
|
||||||
|
import { InvalidClient, InvalidRequest, InvalidScope, UnauthorizedClient } from '../error';
|
||||||
|
import { OAuth2Clients, OAuth2DeviceCodes, OAuth2Tokens } from '../model';
|
||||||
|
import { OAuth2Response } from '../response';
|
||||||
|
|
||||||
|
export class OAuth2DeviceAuthorizationController {
|
||||||
|
static async postRequest({ request }: { request: Request }) {
|
||||||
|
const body = await ApiUtils.getJsonOrFormBody(request);
|
||||||
|
|
||||||
|
let clientId: string | null = null;
|
||||||
|
let clientSecret: string | null = null;
|
||||||
|
if (body.client_id) {
|
||||||
|
clientId = body.client_id;
|
||||||
|
clientSecret = body.client_secret;
|
||||||
|
} else {
|
||||||
|
if (!request.headers?.has('authorization')) {
|
||||||
|
throw new InvalidRequest('No authorization header passed');
|
||||||
|
}
|
||||||
|
|
||||||
|
let pieces = (request.headers.get('authorization') as string).split(' ', 2);
|
||||||
|
if (!pieces || pieces.length !== 2) {
|
||||||
|
throw new InvalidRequest('Authorization header is corrupted');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pieces[0] !== 'Basic') {
|
||||||
|
throw new InvalidRequest(`Unsupported authorization method: ${pieces[0]}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
pieces = Buffer.from(pieces[1], 'base64').toString('ascii').split(':', 2);
|
||||||
|
if (!pieces || pieces.length !== 2) {
|
||||||
|
throw new InvalidRequest('Authorization header has corrupted data');
|
||||||
|
}
|
||||||
|
|
||||||
|
clientId = pieces[0];
|
||||||
|
clientSecret = pieces[1];
|
||||||
|
// console.debug('Client credentials parsed from basic auth header:', clientId, clientSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
throw new InvalidClient('client_id body parameter is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await OAuth2Clients.fetchById(clientId);
|
||||||
|
if (!client || client.activated === 0) {
|
||||||
|
throw new InvalidClient('Client not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// This flow is the only one we allow to use public access (secret not required)
|
||||||
|
if (clientSecret && !OAuth2Clients.checkSecret(client, clientSecret)) {
|
||||||
|
throw new UnauthorizedClient('Invalid client secret');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!OAuth2Clients.checkGrantType(client, 'device_code')) {
|
||||||
|
throw new UnauthorizedClient('This client does not support grant type device');
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = OAuth2Clients.transformScope(body.scope || '');
|
||||||
|
if (!OAuth2Clients.checkScope(client, scope)) {
|
||||||
|
throw new InvalidScope('Client does not allow access to this scope');
|
||||||
|
}
|
||||||
|
|
||||||
|
const issued = await OAuth2DeviceCodes.create(clientId, scope);
|
||||||
|
|
||||||
|
return OAuth2Response.createResponse(200, {
|
||||||
|
...issued,
|
||||||
|
verification_uri: `${env.PUBLIC_URL}/device`,
|
||||||
|
verification_uri_complete: `${env.PUBLIC_URL}/device?user_code=${issued.user_code}`,
|
||||||
|
expires_in: OAuth2Tokens.deviceTtl,
|
||||||
|
interval: OAuth2DeviceCodes.interval
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -19,7 +19,7 @@ export class OAuth2TokenController {
|
|||||||
|
|
||||||
const body = await ApiUtils.getJsonOrFormBody(request);
|
const body = await ApiUtils.getJsonOrFormBody(request);
|
||||||
|
|
||||||
if (body.client_id && body.client_secret) {
|
if (body.client_id) {
|
||||||
clientId = body.client_id as string;
|
clientId = body.client_id as string;
|
||||||
clientSecret = body.client_secret as string;
|
clientSecret = body.client_secret as string;
|
||||||
// console.debug('Client credentials parsed from body parameters', clientId, clientSecret);
|
// console.debug('Client credentials parsed from body parameters', clientId, clientSecret);
|
||||||
@ -54,14 +54,27 @@ export class OAuth2TokenController {
|
|||||||
grantType = body.grant_type as string;
|
grantType = body.grant_type as string;
|
||||||
// console.debug('Parameter grant_type is', grantType);
|
// console.debug('Parameter grant_type is', grantType);
|
||||||
|
|
||||||
|
// The spec does not allow using this grant type directly by this name,
|
||||||
|
// but for verification purposes, we will simplify it below.
|
||||||
|
if (grantType === 'device_code') {
|
||||||
|
throw new UnsupportedGrantType('Grant type does not match any supported type');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grantType === 'urn:ietf:params:oauth:grant-type:device_code') {
|
||||||
|
grantType = 'device_code';
|
||||||
|
}
|
||||||
|
|
||||||
const client = await OAuth2Clients.fetchById(clientId);
|
const client = await OAuth2Clients.fetchById(clientId);
|
||||||
if (!client || client.activated === 0) {
|
if (!client || client.activated === 0) {
|
||||||
throw new InvalidClient('Client not found');
|
throw new InvalidClient('Client not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = OAuth2Clients.checkSecret(client, clientSecret);
|
// Device code flow does not require client authentication
|
||||||
if (!valid) {
|
if (grantType !== 'device_code' || clientSecret) {
|
||||||
throw new UnauthorizedClient('Invalid client secret');
|
const valid = OAuth2Clients.checkSecret(client, clientSecret);
|
||||||
|
if (!valid) {
|
||||||
|
throw new UnauthorizedClient('Invalid client secret');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!OAuth2Clients.checkGrantType(client, grantType) && grantType !== 'refresh_token') {
|
if (!OAuth2Clients.checkGrantType(client, grantType) && grantType !== 'refresh_token') {
|
||||||
@ -72,6 +85,9 @@ export class OAuth2TokenController {
|
|||||||
let tokenResponse: OAuth2TokenResponse = {};
|
let tokenResponse: OAuth2TokenResponse = {};
|
||||||
try {
|
try {
|
||||||
switch (grantType) {
|
switch (grantType) {
|
||||||
|
case 'device_code':
|
||||||
|
tokenResponse = await tokens.device(client, body.device_code);
|
||||||
|
break;
|
||||||
case 'authorization_code':
|
case 'authorization_code':
|
||||||
tokenResponse = await tokens.authorizationCode(client, body.code, body.code_verifier);
|
tokenResponse = await tokens.authorizationCode(client, body.code, body.code_verifier);
|
||||||
break;
|
break;
|
||||||
|
63
src/lib/server/oauth2/controller/tokens/device.ts
Normal file
63
src/lib/server/oauth2/controller/tokens/device.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import type { OAuth2Client } from '$lib/server/drizzle';
|
||||||
|
import { Users } from '$lib/server/users';
|
||||||
|
import { AccessDenied, AuthorizationPending, ExpiredToken, ServerError } from '../../error';
|
||||||
|
import {
|
||||||
|
OAuth2AccessTokens,
|
||||||
|
OAuth2Clients,
|
||||||
|
OAuth2DeviceCodes,
|
||||||
|
OAuth2Tokens,
|
||||||
|
OAuth2Users
|
||||||
|
} from '../../model';
|
||||||
|
import type { OAuth2TokenResponse } from '../../response';
|
||||||
|
|
||||||
|
export async function device(client: OAuth2Client, deviceCode: string) {
|
||||||
|
const token = await OAuth2DeviceCodes.getByDeviceCode(deviceCode, client.client_id);
|
||||||
|
if (!token) {
|
||||||
|
throw new AccessDenied('The Device Code is invalid or the request was denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!OAuth2Tokens.checkTTL(token)) {
|
||||||
|
throw new ExpiredToken('The Device Code is expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token.userId) {
|
||||||
|
throw new AuthorizationPending('Authorization is pending');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanScope = OAuth2Clients.transformScope(token.scope || '');
|
||||||
|
|
||||||
|
const user = await Users.getById(token.userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new ServerError('The user was not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resObj: OAuth2TokenResponse = {
|
||||||
|
token_type: 'bearer'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
resObj.access_token = await OAuth2AccessTokens.create(
|
||||||
|
user.id,
|
||||||
|
client.client_id,
|
||||||
|
cleanScope,
|
||||||
|
OAuth2Tokens.tokenTtl
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
throw new ServerError('Failed to call accessTokens.create method');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanScope.includes('openid')) {
|
||||||
|
try {
|
||||||
|
resObj.id_token = await OAuth2Users.issueIdToken(user, client, cleanScope);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
throw new ServerError('Failed to issue an ID token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resObj.expires_in = OAuth2Tokens.tokenTtl;
|
||||||
|
|
||||||
|
await OAuth2DeviceCodes.removeByCode(deviceCode);
|
||||||
|
|
||||||
|
return resObj;
|
||||||
|
}
|
@ -1,3 +1,4 @@
|
|||||||
export * from './authorizationCode';
|
export * from './authorizationCode';
|
||||||
export * from './clientCredentials';
|
export * from './clientCredentials';
|
||||||
export * from './refreshToken';
|
export * from './refreshToken';
|
||||||
|
export * from './device';
|
||||||
|
@ -1,103 +1,130 @@
|
|||||||
export class OAuth2Error extends Error {
|
export class OAuth2Error extends Error {
|
||||||
public name = 'OAuth2AbstractError';
|
public name = 'OAuth2AbstractError';
|
||||||
public logLevel = 'error';
|
public logLevel = 'error';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public code: string,
|
public code: string,
|
||||||
public message: string,
|
public message: string,
|
||||||
public status: number
|
public status: number
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
Error.captureStackTrace(this, this.constructor);
|
Error.captureStackTrace(this, this.constructor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AccessDenied extends OAuth2Error {
|
export class AccessDenied extends OAuth2Error {
|
||||||
public name = 'OAuth2AccessDenied';
|
public name = 'OAuth2AccessDenied';
|
||||||
public logLevel = 'info';
|
public logLevel = 'info';
|
||||||
|
|
||||||
constructor(msg: string) {
|
constructor(msg: string) {
|
||||||
super('access_denied', msg, 403);
|
super('access_denied', msg, 403);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InvalidClient extends OAuth2Error {
|
export class InvalidClient extends OAuth2Error {
|
||||||
public name = 'OAuth2InvalidClient';
|
public name = 'OAuth2InvalidClient';
|
||||||
public logLevel = 'info';
|
public logLevel = 'info';
|
||||||
|
|
||||||
constructor(msg: string) {
|
constructor(msg: string) {
|
||||||
super('invalid_client', msg, 401);
|
super('invalid_client', msg, 401);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InvalidGrant extends OAuth2Error {
|
export class InvalidGrant extends OAuth2Error {
|
||||||
public name = 'OAuth2InvalidGrant';
|
public name = 'OAuth2InvalidGrant';
|
||||||
public logLevel = 'info';
|
public logLevel = 'info';
|
||||||
|
|
||||||
constructor(msg: string) {
|
constructor(msg: string) {
|
||||||
super('invalid_grant', msg, 400);
|
super('invalid_grant', msg, 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InvalidRequest extends OAuth2Error {
|
export class InvalidRequest extends OAuth2Error {
|
||||||
public name = 'OAuth2InvalidRequest';
|
public name = 'OAuth2InvalidRequest';
|
||||||
public logLevel = 'info';
|
public logLevel = 'info';
|
||||||
|
|
||||||
constructor(msg: string) {
|
constructor(msg: string) {
|
||||||
super('invalid_request', msg, 400);
|
super('invalid_request', msg, 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InvalidScope extends OAuth2Error {
|
export class InvalidScope extends OAuth2Error {
|
||||||
public name = 'OAuth2InvalidScope';
|
public name = 'OAuth2InvalidScope';
|
||||||
public logLevel = 'info';
|
public logLevel = 'info';
|
||||||
|
|
||||||
constructor(msg: string) {
|
constructor(msg: string) {
|
||||||
super('invalid_scope', msg, 400);
|
super('invalid_scope', msg, 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ServerError extends OAuth2Error {
|
export class ServerError extends OAuth2Error {
|
||||||
public name = 'OAuth2ServerError';
|
public name = 'OAuth2ServerError';
|
||||||
public logLevel = 'error';
|
public logLevel = 'error';
|
||||||
|
|
||||||
constructor(msg: string) {
|
constructor(msg: string) {
|
||||||
super('server_error', msg, 500);
|
super('server_error', msg, 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UnauthorizedClient extends OAuth2Error {
|
export class UnauthorizedClient extends OAuth2Error {
|
||||||
public name = 'OAuth2UnauthorizedClient';
|
public name = 'OAuth2UnauthorizedClient';
|
||||||
public logLevel = 'info';
|
public logLevel = 'info';
|
||||||
|
|
||||||
constructor(msg: string) {
|
constructor(msg: string) {
|
||||||
super('unauthorized_client', msg, 400);
|
super('unauthorized_client', msg, 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UnsupportedGrantType extends OAuth2Error {
|
export class UnsupportedGrantType extends OAuth2Error {
|
||||||
public name = 'OAuth2UnsupportedGrantType';
|
public name = 'OAuth2UnsupportedGrantType';
|
||||||
public logLevel = 'info';
|
public logLevel = 'info';
|
||||||
|
|
||||||
constructor(msg: string) {
|
constructor(msg: string) {
|
||||||
super('unsupported_grant_type', msg, 400);
|
super('unsupported_grant_type', msg, 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UnsupportedResponseType extends OAuth2Error {
|
export class UnsupportedResponseType extends OAuth2Error {
|
||||||
public name = 'OAuth2UnsupportedResponseType';
|
public name = 'OAuth2UnsupportedResponseType';
|
||||||
public logLevel = 'info';
|
public logLevel = 'info';
|
||||||
|
|
||||||
constructor(msg: string) {
|
constructor(msg: string) {
|
||||||
super('unsupported_response_type', msg, 400);
|
super('unsupported_response_type', msg, 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InteractionRequired extends OAuth2Error {
|
export class InteractionRequired extends OAuth2Error {
|
||||||
public name = 'OAuth2InteractionRequired';
|
public name = 'OAuth2InteractionRequired';
|
||||||
public logLevel = 'info';
|
public logLevel = 'info';
|
||||||
|
|
||||||
constructor(msg: string) {
|
constructor(msg: string) {
|
||||||
super('interaction_required', msg, 400);
|
super('interaction_required', msg, 400);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthorizationPending extends OAuth2Error {
|
||||||
|
public name = 'OAuth2AuthorizationPending';
|
||||||
|
public logLevel = 'info';
|
||||||
|
|
||||||
|
constructor(msg: string) {
|
||||||
|
super('authorization_pending', msg, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SlowDown extends OAuth2Error {
|
||||||
|
public name = 'OAuth2SlowDown';
|
||||||
|
public logLevel = 'info';
|
||||||
|
|
||||||
|
constructor(msg: string) {
|
||||||
|
super('slow_down', msg, 429);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExpiredToken extends OAuth2Error {
|
||||||
|
public name = 'OAuth2ExpiredToken';
|
||||||
|
public logLevel = 'info';
|
||||||
|
|
||||||
|
constructor(msg: string) {
|
||||||
|
super('expired_token', msg, 400);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -49,6 +49,7 @@ export class OAuth2Clients {
|
|||||||
public static availableGrantTypes = [
|
public static availableGrantTypes = [
|
||||||
'authorization_code',
|
'authorization_code',
|
||||||
'client_credentials',
|
'client_credentials',
|
||||||
|
'device_code',
|
||||||
'refresh_token',
|
'refresh_token',
|
||||||
'id_token',
|
'id_token',
|
||||||
'implicit'
|
'implicit'
|
||||||
@ -66,7 +67,12 @@ export class OAuth2Clients {
|
|||||||
|
|
||||||
// Non-administrator capabilities
|
// Non-administrator capabilities
|
||||||
public static implicitGrantTypes = ['id_token', 'implicit'];
|
public static implicitGrantTypes = ['id_token', 'implicit'];
|
||||||
public static userSetGrants = ['authorization_code', 'client_credentials', 'refresh_token'];
|
public static userSetGrants = [
|
||||||
|
'authorization_code',
|
||||||
|
'device_code',
|
||||||
|
'client_credentials',
|
||||||
|
'refresh_token'
|
||||||
|
];
|
||||||
public static userSetScopes = [
|
public static userSetScopes = [
|
||||||
'profile',
|
'profile',
|
||||||
'picture',
|
'picture',
|
||||||
|
@ -6,7 +6,7 @@ import {
|
|||||||
type OAuth2Token,
|
type OAuth2Token,
|
||||||
type User
|
type User
|
||||||
} from '$lib/server/drizzle';
|
} from '$lib/server/drizzle';
|
||||||
import { and, eq, sql } from 'drizzle-orm';
|
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||||
import { OAuth2Clients } from './client';
|
import { OAuth2Clients } from './client';
|
||||||
import { Users } from '$lib/server/users';
|
import { Users } from '$lib/server/users';
|
||||||
import { CryptoUtils } from '$lib/server/crypto-utils';
|
import { CryptoUtils } from '$lib/server/crypto-utils';
|
||||||
@ -15,6 +15,7 @@ export type CodeChallengeMethod = 'plain' | 'S256';
|
|||||||
|
|
||||||
export enum OAuth2TokenType {
|
export enum OAuth2TokenType {
|
||||||
CODE = 'code',
|
CODE = 'code',
|
||||||
|
DEVICE_CODE = 'device_code',
|
||||||
ACCESS_TOKEN = 'access_token',
|
ACCESS_TOKEN = 'access_token',
|
||||||
REFRESH_TOKEN = 'refresh_token'
|
REFRESH_TOKEN = 'refresh_token'
|
||||||
}
|
}
|
||||||
@ -34,6 +35,7 @@ export interface OAuth2RefreshToken extends OAuth2Token {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class OAuth2Tokens {
|
export class OAuth2Tokens {
|
||||||
|
static deviceTtl = 1800;
|
||||||
static codeTtl = 3600;
|
static codeTtl = 3600;
|
||||||
static tokenTtl = 604800;
|
static tokenTtl = 604800;
|
||||||
static refreshTtl = 3.154e7;
|
static refreshTtl = 3.154e7;
|
||||||
@ -125,11 +127,11 @@ export class OAuth2Tokens {
|
|||||||
* @param token Access token to check
|
* @param token Access token to check
|
||||||
* @returns true if still valid
|
* @returns true if still valid
|
||||||
*/
|
*/
|
||||||
static checkTTL(token: OAuth2AccessToken): boolean {
|
static checkTTL(token: OAuth2Token): boolean {
|
||||||
return new Date().getTime() < new Date(token.expires_at).getTime();
|
return new Date().getTime() < new Date(token.expires_at).getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
static getTTL(token: OAuth2AccessToken): number {
|
static getTTL(token: OAuth2Token): number {
|
||||||
return new Date(token.expires_at).getTime() - Date.now();
|
return new Date(token.expires_at).getTime() - Date.now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -325,3 +327,89 @@ export class OAuth2RefreshTokens {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class OAuth2DeviceCodes {
|
||||||
|
static interval = 5;
|
||||||
|
|
||||||
|
static async create(clientId: string, scope: string | string[]) {
|
||||||
|
const client = await OAuth2Clients.fetchById(clientId);
|
||||||
|
const deviceCode = CryptoUtils.generateString(32);
|
||||||
|
const userCode =
|
||||||
|
`${CryptoUtils.generateString(3)}-${CryptoUtils.generateString(3)}`.toUpperCase();
|
||||||
|
|
||||||
|
const scopes = (!Array.isArray(scope) ? OAuth2Clients.splitScope(scope) : scope).join(' ');
|
||||||
|
const expiresAt = new Date(Date.now() + OAuth2Tokens.deviceTtl * 1000);
|
||||||
|
|
||||||
|
await OAuth2Tokens.insert(
|
||||||
|
deviceCode,
|
||||||
|
OAuth2TokenType.DEVICE_CODE,
|
||||||
|
client,
|
||||||
|
scopes,
|
||||||
|
expiresAt,
|
||||||
|
undefined,
|
||||||
|
userCode
|
||||||
|
);
|
||||||
|
|
||||||
|
return { device_code: deviceCode, user_code: userCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getByDeviceCode(code: string, clientId: string) {
|
||||||
|
const deviceCode = await OAuth2Tokens.fetchByToken(code, OAuth2TokenType.DEVICE_CODE);
|
||||||
|
if (!deviceCode) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await OAuth2Clients.fetchById(clientId);
|
||||||
|
if (client.id !== deviceCode.clientId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return deviceCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getByUserCode(code: string) {
|
||||||
|
const [retval] = await DB.drizzle
|
||||||
|
.select({
|
||||||
|
id: oauth2Token.id,
|
||||||
|
clientId: oauth2Token.clientId,
|
||||||
|
scope: oauth2Token.scope,
|
||||||
|
expires_at: oauth2Token.expires_at
|
||||||
|
})
|
||||||
|
.from(oauth2Token)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
sql`upper(${oauth2Token.nonce}) = ${code.toUpperCase()}`,
|
||||||
|
eq(oauth2Token.type, OAuth2TokenType.DEVICE_CODE),
|
||||||
|
isNull(oauth2Token.userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return retval;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async authorizeByUserCode(user: User, code: string, decision: boolean) {
|
||||||
|
const retval = await OAuth2DeviceCodes.getByUserCode(code);
|
||||||
|
if (!retval) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision) {
|
||||||
|
// Associate with user, this marks it as authorized
|
||||||
|
await DB.drizzle
|
||||||
|
.update(oauth2Token)
|
||||||
|
.set({ userId: user.id, expires_at: retval.expires_at })
|
||||||
|
.where(eq(oauth2Token.id, retval.id));
|
||||||
|
} else {
|
||||||
|
await DB.drizzle.delete(oauth2Token).where(eq(oauth2Token.id, retval.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async removeByCode(code: string) {
|
||||||
|
const find = await OAuth2Tokens.fetchByToken(code, OAuth2TokenType.DEVICE_CODE);
|
||||||
|
|
||||||
|
await OAuth2Tokens.remove(find);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -6,6 +6,16 @@ export interface UserSession {
|
|||||||
privileges?: string[];
|
privileges?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OAuth2ClientInfo {
|
||||||
|
links: { url: string; type: string }[];
|
||||||
|
client_id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
grants: string;
|
||||||
|
allowedScopes: string[];
|
||||||
|
disallowedScopes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaginationMeta {
|
export interface PaginationMeta {
|
||||||
rowCount: number;
|
rowCount: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
@ -10,6 +10,7 @@ export const GET = async () =>
|
|||||||
jwks_uri: `${publicEnv.PUBLIC_URL}/.well-known/jwks.json`,
|
jwks_uri: `${publicEnv.PUBLIC_URL}/.well-known/jwks.json`,
|
||||||
userinfo_endpoint: `${publicEnv.PUBLIC_URL}/api/user`,
|
userinfo_endpoint: `${publicEnv.PUBLIC_URL}/api/user`,
|
||||||
introspection_endpoint: `${publicEnv.PUBLIC_URL}/oauth2/introspect`,
|
introspection_endpoint: `${publicEnv.PUBLIC_URL}/oauth2/introspect`,
|
||||||
|
device_authorization_endpoint: `${publicEnv.PUBLIC_URL}/oauth2/device_authorization`,
|
||||||
response_types_supported: ['code', 'id_token'],
|
response_types_supported: ['code', 'id_token'],
|
||||||
id_token_signing_alg_values_supported: [privateEnv.JWT_ALGORITHM],
|
id_token_signing_alg_values_supported: [privateEnv.JWT_ALGORITHM],
|
||||||
subject_types_supported: ['public'],
|
subject_types_supported: ['public'],
|
||||||
@ -29,5 +30,9 @@ export const GET = async () =>
|
|||||||
'email_verified'
|
'email_verified'
|
||||||
],
|
],
|
||||||
code_challenge_methods_supported: ['plain', 'S256'],
|
code_challenge_methods_supported: ['plain', 'S256'],
|
||||||
grant_types_supported: ['authorization_code', 'refresh_token']
|
grant_types_supported: [
|
||||||
|
'authorization_code',
|
||||||
|
'refresh_token',
|
||||||
|
'urn:ietf:params:oauth:grant-type:device_code'
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
75
src/routes/device/+page.server.ts
Normal file
75
src/routes/device/+page.server.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { OAuth2Clients, OAuth2DeviceCodes, OAuth2Users } from '$lib/server/oauth2/index.js';
|
||||||
|
import { Users } from '$lib/server/users';
|
||||||
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
|
import { RateLimiter } from 'sveltekit-rate-limiter/server';
|
||||||
|
|
||||||
|
const limiter = new RateLimiter({
|
||||||
|
IP: [6, 'm']
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
default: async (event) => {
|
||||||
|
const { locals, request, url } = event;
|
||||||
|
const currentUser = await Users.getBySession(locals.session.data?.user);
|
||||||
|
if (!currentUser) {
|
||||||
|
await locals.session.destroy();
|
||||||
|
return redirect(301, `/login?redirectTo=${encodeURIComponent(url.pathname)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.formData();
|
||||||
|
const code = body.get('code') as string;
|
||||||
|
if (!body.has('code') || !code) {
|
||||||
|
if (await limiter.isLimited(event)) throw error(429, "You're doing that too much!");
|
||||||
|
return fail(400, { errors: ['noCode'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await OAuth2DeviceCodes.getByUserCode(code);
|
||||||
|
if (!token?.clientId) {
|
||||||
|
if (await limiter.isLimited(event)) throw error(429, "You're doing that too much!");
|
||||||
|
return fail(404, { errors: ['invalidCode'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopes = OAuth2Clients.transformScope(token.scope || '');
|
||||||
|
const client = await OAuth2Clients.fetchById(token.clientId);
|
||||||
|
|
||||||
|
// Due to the risk of this API being used with unauthenticated clients,
|
||||||
|
// implicit consent is not allowed like it is with normal authorization flow.
|
||||||
|
|
||||||
|
if (!body.has('decision')) {
|
||||||
|
const clientInfo = await OAuth2Clients.authorizeClientInfo(client, scopes);
|
||||||
|
|
||||||
|
return { code, client: clientInfo, errors: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const consented = body.get('decision') === '1';
|
||||||
|
|
||||||
|
const success = await OAuth2DeviceCodes.authorizeByUserCode(
|
||||||
|
currentUser,
|
||||||
|
body.get('code') as string,
|
||||||
|
consented
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return fail(404, { errors: ['invalidCode'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (consented) {
|
||||||
|
await OAuth2Users.saveConsent(currentUser, client, scopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { errors: [] };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const load = async ({ locals, url }) => {
|
||||||
|
const userInfo = locals.session.data?.user;
|
||||||
|
const currentUser = await Users.getBySession(userInfo);
|
||||||
|
if (!userInfo || !currentUser) {
|
||||||
|
await locals.session.destroy();
|
||||||
|
return redirect(301, `/login?redirectTo=${encodeURIComponent(url.pathname)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: userInfo
|
||||||
|
};
|
||||||
|
};
|
82
src/routes/device/+page.svelte
Normal file
82
src/routes/device/+page.svelte
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ActionData, PageData } from './$types';
|
||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import Alert from '$lib/components/Alert.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import MainContainer from '$lib/components/container/MainContainer.svelte';
|
||||||
|
import OAuth2AuthorizeCard from '$lib/components/oauth2/OAuth2AuthorizeCard.svelte';
|
||||||
|
import OAuth2ScopesCard from '$lib/components/oauth2/OAuth2ScopesCard.svelte';
|
||||||
|
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
|
||||||
|
import FormControl from '$lib/components/form/FormControl.svelte';
|
||||||
|
import FormErrors from '$lib/components/form/FormErrors.svelte';
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
export let form: ActionData;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
{#if form?.client}
|
||||||
|
<title
|
||||||
|
>{$t('oauth2.authorize.title')} "{form.client?.title || ''}" - {env.PUBLIC_SITE_NAME}</title
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<title>{$t('oauth2.device.title')} - {env.PUBLIC_SITE_NAME}</title>
|
||||||
|
{/if}
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<MainContainer>
|
||||||
|
<h1 class="title">{env.PUBLIC_SITE_NAME}</h1>
|
||||||
|
|
||||||
|
{#if form?.client}
|
||||||
|
<h2 class="title">{$t('oauth2.authorize.title')}</h2>
|
||||||
|
|
||||||
|
<OAuth2AuthorizeCard user={data.user} client={form.client} />
|
||||||
|
|
||||||
|
<OAuth2ScopesCard client={form.client} />
|
||||||
|
|
||||||
|
<div class="decision">
|
||||||
|
<form action="" method="POST">
|
||||||
|
<input type="hidden" value={form.code} name="code" />
|
||||||
|
<input type="hidden" value="1" name="decision" />
|
||||||
|
<Button type="submit" variant="primary">{$t('oauth2.authorize.authorize')}</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form action="" method="POST">
|
||||||
|
<input type="hidden" value={form.code} name="code" />
|
||||||
|
<input type="hidden" value="0" name="decision" />
|
||||||
|
<Button type="submit" variant="link">{$t('oauth2.authorize.reject')}</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{:else if form && !form.errors?.length}
|
||||||
|
<Alert type="success">{$t('oauth2.device.success')}</Alert>
|
||||||
|
{:else}
|
||||||
|
<h2 class="title">{$t('oauth2.device.title')}</h2>
|
||||||
|
<p>{$t('oauth2.device.description')}</p>
|
||||||
|
|
||||||
|
<form action="" method="POST">
|
||||||
|
<FormWrapper>
|
||||||
|
<FormErrors errors={form?.errors} prefix="oauth2.errors" />
|
||||||
|
<FormControl>
|
||||||
|
<label for="form-code">{$t('oauth2.device.deviceCode')}</label>
|
||||||
|
<input type="text" autocomplete="off" name="code" id="form-code" />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Button type="submit" variant="primary">{$t('common.submit')}</Button>
|
||||||
|
</FormWrapper>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</MainContainer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.title {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,13 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { assets } from '$app/paths';
|
|
||||||
import { env } from '$env/dynamic/public';
|
import { env } from '$env/dynamic/public';
|
||||||
import Alert from '$lib/components/Alert.svelte';
|
import Alert from '$lib/components/Alert.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
import ColumnView from '$lib/components/container/ColumnView.svelte';
|
import ColumnView from '$lib/components/container/ColumnView.svelte';
|
||||||
import MainContainer from '$lib/components/container/MainContainer.svelte';
|
import MainContainer from '$lib/components/container/MainContainer.svelte';
|
||||||
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
|
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
import OAuth2AuthorizeCard from '$lib/components/oauth2/OAuth2AuthorizeCard.svelte';
|
||||||
|
import OAuth2ScopesCard from '$lib/components/oauth2/OAuth2ScopesCard.svelte';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
</script>
|
</script>
|
||||||
@ -34,55 +34,9 @@
|
|||||||
<h1 class="title">{env.PUBLIC_SITE_NAME}</h1>
|
<h1 class="title">{env.PUBLIC_SITE_NAME}</h1>
|
||||||
<h2 class="title">{$t('oauth2.authorize.title')}</h2>
|
<h2 class="title">{$t('oauth2.authorize.title')}</h2>
|
||||||
|
|
||||||
<div class="user-client-wrapper">
|
<OAuth2AuthorizeCard user={data.user} client={data.client} />
|
||||||
{#if data.user}
|
|
||||||
<div class="card">
|
|
||||||
<AvatarCard src={`/api/avatar/${data.user.uuid}`} alt={data.user.name}>
|
|
||||||
<div class="card-inner">
|
|
||||||
<span class="card-display-name">{data.user.name}</span>
|
|
||||||
<span class="card-user-name">@{data.user.username}</span>
|
|
||||||
</div>
|
|
||||||
</AvatarCard>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="graphic" aria-hidden="true"></div>
|
|
||||||
<div class="card">
|
|
||||||
<AvatarCard src={`/api/avatar/client/${data.client.client_id}`} alt={data.client.title}>
|
|
||||||
<div class="card-inner">
|
|
||||||
<span class="card-display-name">{data.client.title}</span>
|
|
||||||
<span class="card-user-name">{data.client.description}</span>
|
|
||||||
|
|
||||||
<div class="card-links">
|
<OAuth2ScopesCard client={data.client} />
|
||||||
{#each data.client.links as link}
|
|
||||||
<a href={link.url} target="_blank" rel="nofollow noreferrer"
|
|
||||||
>{$t(`oauth2.authorize.link.${link.type}`)}</a
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AvatarCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="scope">
|
|
||||||
{#if data.client.allowedScopes?.length}
|
|
||||||
<h3>{$t('oauth2.authorize.allowed')}</h3>
|
|
||||||
<div class="scope-list scope-list-allowed">
|
|
||||||
{#each data.client.allowedScopes as scope}
|
|
||||||
<span class="scope-list-item">{$t(`oauth2.authorize.scope.${scope}`)}</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if data.client.disallowedScopes?.length}
|
|
||||||
<h3>{$t('oauth2.authorize.disallowed')}</h3>
|
|
||||||
<div class="scope-list scope-list-disallowed">
|
|
||||||
{#each data.client.disallowedScopes as scope}
|
|
||||||
<span class="scope-list-item">{$t(`oauth2.authorize.scope.${scope}`)}</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="decision">
|
<div class="decision">
|
||||||
<form action="" method="POST">
|
<form action="" method="POST">
|
||||||
@ -103,98 +57,10 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-client-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-evenly;
|
|
||||||
|
|
||||||
& .graphic {
|
|
||||||
background-image: url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 style=%27width:24px;height:24px%27 viewBox=%270 0 24 24%27%3E%3Cpath fill=%27currentColor%27 d=%27M10,4H14V13L17.5,9.5L19.92,11.92L12,19.84L4.08,11.92L6.5,9.5L10,13V4Z%27 /%3E%3C/svg%3E');
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
opacity: 0.4;
|
|
||||||
margin: 20px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .card {
|
|
||||||
width: 40%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
& .graphic {
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .card {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.card-inner {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
.card-display-name {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.card-links {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
.scope {
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 2rem auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scope-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scope-list-allowed {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
|
||||||
& .scope-list-item::before {
|
|
||||||
content: url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 style=%27width:24px;height:24px%27 viewBox=%270 0 24 24%27%3E%3Cpath fill=%27%2300f000%27 d=%27M9,20.42L2.79,14.21L5.62,11.38L9,14.77L18.88,4.88L21.71,7.71L9,20.42Z%27 /%3E%3C/svg%3E');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scope-list-disallowed .scope-list-item::before {
|
|
||||||
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' style='width:24px;height:24px' viewBox='0 0 24 24'%3E%3Cpath fill='%23f00000' d='M20 6.91L17.09 4L12 9.09L6.91 4L4 6.91L9.09 12L4 17.09L6.91 20L12 14.91L17.09 20L20 17.09L14.91 12L20 6.91Z' /%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.scope-list-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-weight: 700;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
display: block;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
margin: 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.decision {
|
.decision {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(html[theme-base='dark']) .user-client-wrapper .graphic {
|
|
||||||
filter: invert();
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
23
src/routes/oauth2/device_authorization/+server.ts
Normal file
23
src/routes/oauth2/device_authorization/+server.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { OAuth2Error } from '$lib/server/oauth2/error.js';
|
||||||
|
import { OAuth2Response } from '$lib/server/oauth2/response.js';
|
||||||
|
import { OAuth2DeviceAuthorizationController } from '$lib/server/oauth2/controller/device-authorization.js';
|
||||||
|
import { RateLimiter } from 'sveltekit-rate-limiter/server';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
const limiter = new RateLimiter({
|
||||||
|
IP: [6, 'm']
|
||||||
|
});
|
||||||
|
|
||||||
|
export const POST = async (event) => {
|
||||||
|
const { request, url } = event;
|
||||||
|
if (await limiter.isLimited(event)) error(429, "You're doing that too much!");
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await OAuth2DeviceAuthorizationController.postRequest({ request });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OAuth2Error) {
|
||||||
|
return OAuth2Response.error(url, error);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
@ -1,9 +1,20 @@
|
|||||||
import { OAuth2Error } from '$lib/server/oauth2/error.js';
|
import { OAuth2Error, SlowDown } from '$lib/server/oauth2/error.js';
|
||||||
import { OAuth2Response } from '$lib/server/oauth2/response.js';
|
import { OAuth2Response } from '$lib/server/oauth2/response.js';
|
||||||
import { OAuth2TokenController } from '$lib/server/oauth2/controller/token.js';
|
import { OAuth2TokenController } from '$lib/server/oauth2/controller/token.js';
|
||||||
|
import { RateLimiter } from 'sveltekit-rate-limiter/server';
|
||||||
|
|
||||||
|
const limiter = new RateLimiter({
|
||||||
|
IP: [15, 'm']
|
||||||
|
});
|
||||||
|
|
||||||
|
export const POST = async (event) => {
|
||||||
|
const { request, url } = event;
|
||||||
|
|
||||||
export const POST = async ({ request, url }) => {
|
|
||||||
try {
|
try {
|
||||||
|
if (await limiter.isLimited(event)) {
|
||||||
|
throw new SlowDown('Please, slow down!');
|
||||||
|
}
|
||||||
|
|
||||||
return await OAuth2TokenController.postHandler({ request, url });
|
return await OAuth2TokenController.postHandler({ request, url });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof OAuth2Error) {
|
if (error instanceof OAuth2Error) {
|
||||||
|
@ -245,6 +245,7 @@
|
|||||||
|
|
||||||
<h2>{$t('admin.oauth2.grants')}</h2>
|
<h2>{$t('admin.oauth2.grants')}</h2>
|
||||||
<p>{$t('admin.oauth2.grantsHint')}</p>
|
<p>{$t('admin.oauth2.grantsHint')}</p>
|
||||||
|
<p><b>{$t('admin.oauth2.grantsWarning')}</b></p>
|
||||||
|
|
||||||
<form action="?/grants" method="POST">
|
<form action="?/grants" method="POST">
|
||||||
<div class="checkbox-grid">
|
<div class="checkbox-grid">
|
||||||
@ -363,6 +364,14 @@
|
|||||||
></code
|
></code
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
{$t('admin.oauth2.apis.device')} -
|
||||||
|
<code
|
||||||
|
><a href={`/oauth2/device_authorization`} data-sveltekit-preload-data="off"
|
||||||
|
>{env.PUBLIC_URL}/oauth2/device_authorization</a
|
||||||
|
></code
|
||||||
|
>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
{$t('admin.oauth2.apis.userinfo')} -
|
{$t('admin.oauth2.apis.userinfo')} -
|
||||||
<code
|
<code
|
||||||
|
Loading…
Reference in New Issue
Block a user