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,
|
||||
"tag": "0002_whole_vivisector",
|
||||
"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.",
|
||||
"grants": "Available grant types",
|
||||
"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",
|
||||
"owner": "Created by",
|
||||
"ownerMe": "that's you!",
|
||||
@ -88,12 +89,14 @@
|
||||
"token": "OAuth2 Token endpoint",
|
||||
"introspect": "OAuth2 Introspection endpoint",
|
||||
"userinfo": "User information endpoint (Bearer)",
|
||||
"device": "OAuth2 Device Authorization endpoint",
|
||||
"openid": "OpenID Connect configuration"
|
||||
},
|
||||
"grantTexts": {
|
||||
"authorization_code": "Authorization code",
|
||||
"client_credentials": "Client credentials",
|
||||
"refresh_token": "Refresh token",
|
||||
"device_code": "Device authorization",
|
||||
"implicit": "Implicit token",
|
||||
"id_token": "ID token (OpenID Connect)"
|
||||
},
|
||||
@ -102,7 +105,7 @@
|
||||
"profile": "Basic profile information",
|
||||
"email": "Access user email address",
|
||||
"privileges": "Access user privilege list",
|
||||
"management": "Manage your application",
|
||||
"management": "Manage your application (with Client credientials only)",
|
||||
"account": "Change user account settings",
|
||||
"openid": "Get an ID token JWT (OpenID Connect)"
|
||||
},
|
||||
|
@ -18,5 +18,15 @@
|
||||
"privacy": "Privacy policy",
|
||||
"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', {
|
||||
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(),
|
||||
scope: text('scope'),
|
||||
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);
|
||||
|
||||
if (body.client_id && body.client_secret) {
|
||||
if (body.client_id) {
|
||||
clientId = body.client_id as string;
|
||||
clientSecret = body.client_secret as string;
|
||||
// console.debug('Client credentials parsed from body parameters', clientId, clientSecret);
|
||||
@ -54,15 +54,28 @@ export class OAuth2TokenController {
|
||||
grantType = body.grant_type as string;
|
||||
// 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);
|
||||
if (!client || client.activated === 0) {
|
||||
throw new InvalidClient('Client not found');
|
||||
}
|
||||
|
||||
// Device code flow does not require client authentication
|
||||
if (grantType !== 'device_code' || clientSecret) {
|
||||
const valid = OAuth2Clients.checkSecret(client, clientSecret);
|
||||
if (!valid) {
|
||||
throw new UnauthorizedClient('Invalid client secret');
|
||||
}
|
||||
}
|
||||
|
||||
if (!OAuth2Clients.checkGrantType(client, grantType) && grantType !== 'refresh_token') {
|
||||
throw new UnauthorizedClient('Invalid grant type for the client');
|
||||
@ -72,6 +85,9 @@ export class OAuth2TokenController {
|
||||
let tokenResponse: OAuth2TokenResponse = {};
|
||||
try {
|
||||
switch (grantType) {
|
||||
case 'device_code':
|
||||
tokenResponse = await tokens.device(client, body.device_code);
|
||||
break;
|
||||
case 'authorization_code':
|
||||
tokenResponse = await tokens.authorizationCode(client, body.code, body.code_verifier);
|
||||
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 './clientCredentials';
|
||||
export * from './refreshToken';
|
||||
export * from './device';
|
||||
|
@ -101,3 +101,30 @@ export class InteractionRequired extends OAuth2Error {
|
||||
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 = [
|
||||
'authorization_code',
|
||||
'client_credentials',
|
||||
'device_code',
|
||||
'refresh_token',
|
||||
'id_token',
|
||||
'implicit'
|
||||
@ -66,7 +67,12 @@ export class OAuth2Clients {
|
||||
|
||||
// Non-administrator capabilities
|
||||
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 = [
|
||||
'profile',
|
||||
'picture',
|
||||
|
@ -6,7 +6,7 @@ import {
|
||||
type OAuth2Token,
|
||||
type User
|
||||
} from '$lib/server/drizzle';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||
import { OAuth2Clients } from './client';
|
||||
import { Users } from '$lib/server/users';
|
||||
import { CryptoUtils } from '$lib/server/crypto-utils';
|
||||
@ -15,6 +15,7 @@ export type CodeChallengeMethod = 'plain' | 'S256';
|
||||
|
||||
export enum OAuth2TokenType {
|
||||
CODE = 'code',
|
||||
DEVICE_CODE = 'device_code',
|
||||
ACCESS_TOKEN = 'access_token',
|
||||
REFRESH_TOKEN = 'refresh_token'
|
||||
}
|
||||
@ -34,6 +35,7 @@ export interface OAuth2RefreshToken extends OAuth2Token {
|
||||
}
|
||||
|
||||
export class OAuth2Tokens {
|
||||
static deviceTtl = 1800;
|
||||
static codeTtl = 3600;
|
||||
static tokenTtl = 604800;
|
||||
static refreshTtl = 3.154e7;
|
||||
@ -125,11 +127,11 @@ export class OAuth2Tokens {
|
||||
* @param token Access token to check
|
||||
* @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();
|
||||
}
|
||||
|
||||
static getTTL(token: OAuth2AccessToken): number {
|
||||
static getTTL(token: OAuth2Token): number {
|
||||
return new Date(token.expires_at).getTime() - Date.now();
|
||||
}
|
||||
}
|
||||
@ -325,3 +327,89 @@ export class OAuth2RefreshTokens {
|
||||
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[];
|
||||
}
|
||||
|
||||
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 {
|
||||
rowCount: number;
|
||||
pageSize: number;
|
||||
|
@ -10,6 +10,7 @@ export const GET = async () =>
|
||||
jwks_uri: `${publicEnv.PUBLIC_URL}/.well-known/jwks.json`,
|
||||
userinfo_endpoint: `${publicEnv.PUBLIC_URL}/api/user`,
|
||||
introspection_endpoint: `${publicEnv.PUBLIC_URL}/oauth2/introspect`,
|
||||
device_authorization_endpoint: `${publicEnv.PUBLIC_URL}/oauth2/device_authorization`,
|
||||
response_types_supported: ['code', 'id_token'],
|
||||
id_token_signing_alg_values_supported: [privateEnv.JWT_ALGORITHM],
|
||||
subject_types_supported: ['public'],
|
||||
@ -29,5 +30,9 @@ export const GET = async () =>
|
||||
'email_verified'
|
||||
],
|
||||
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">
|
||||
import { assets } from '$app/paths';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import Alert from '$lib/components/Alert.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ColumnView from '$lib/components/container/ColumnView.svelte';
|
||||
import MainContainer from '$lib/components/container/MainContainer.svelte';
|
||||
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
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;
|
||||
</script>
|
||||
@ -34,55 +34,9 @@
|
||||
<h1 class="title">{env.PUBLIC_SITE_NAME}</h1>
|
||||
<h2 class="title">{$t('oauth2.authorize.title')}</h2>
|
||||
|
||||
<div class="user-client-wrapper">
|
||||
{#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>
|
||||
<OAuth2AuthorizeCard user={data.user} client={data.client} />
|
||||
|
||||
<div class="card-links">
|
||||
{#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>
|
||||
<OAuth2ScopesCard client={data.client} />
|
||||
|
||||
<div class="decision">
|
||||
<form action="" method="POST">
|
||||
@ -103,98 +57,10 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
:global(html[theme-base='dark']) .user-client-wrapper .graphic {
|
||||
filter: invert();
|
||||
}
|
||||
</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 { 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 {
|
||||
if (await limiter.isLimited(event)) {
|
||||
throw new SlowDown('Please, slow down!');
|
||||
}
|
||||
|
||||
return await OAuth2TokenController.postHandler({ request, url });
|
||||
} catch (error) {
|
||||
if (error instanceof OAuth2Error) {
|
||||
|
@ -245,6 +245,7 @@
|
||||
|
||||
<h2>{$t('admin.oauth2.grants')}</h2>
|
||||
<p>{$t('admin.oauth2.grantsHint')}</p>
|
||||
<p><b>{$t('admin.oauth2.grantsWarning')}</b></p>
|
||||
|
||||
<form action="?/grants" method="POST">
|
||||
<div class="checkbox-grid">
|
||||
@ -363,6 +364,14 @@
|
||||
></code
|
||||
>
|
||||
</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>
|
||||
{$t('admin.oauth2.apis.userinfo')} -
|
||||
<code
|
||||
|
Loading…
Reference in New Issue
Block a user