Add device flow

This commit is contained in:
Evert Prants 2024-06-08 15:39:52 +03:00
parent 77a4aa1e4e
commit cfa8ff7048
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
23 changed files with 1807 additions and 211 deletions

View File

@ -0,0 +1 @@
ALTER TABLE `o_auth2_token` MODIFY COLUMN `type` enum('code','device_code','access_token','refresh_token') NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@ -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
} }
] ]
} }

View 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>

View 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>

View File

@ -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)"
}, },

View File

@ -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"
} }
} }

View File

@ -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' })

View 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
});
}
}

View File

@ -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;

View 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;
}

View File

@ -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';

View File

@ -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);
}
} }

View File

@ -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',

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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'
]
}); });

View 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
};
};

View 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>

View File

@ -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>

View 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;
}
};

View File

@ -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) {

View File

@ -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