authorize view
This commit is contained in:
parent
610b3a8c09
commit
d47b68736d
@ -1,13 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let user: { uuid: string; username: string };
|
export let src = '';
|
||||||
export let cacheBust: number | undefined = undefined;
|
export let alt = '';
|
||||||
|
|
||||||
$: avatarSource = `/api/avatar/${user.uuid}${cacheBust ? `?t=${cacheBust}` : ''}`;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="avatar-wrapper{$$slots.default ? ' with-actions' : ''}">
|
<div class="avatar-wrapper{$$slots.default ? ' with-actions' : ''}">
|
||||||
<div class="image-wrapper">
|
<div class="image-wrapper">
|
||||||
<img src={avatarSource} alt={user.username} />
|
<img {src} {alt} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions-wrapper">
|
<div class="actions-wrapper">
|
||||||
|
@ -2,15 +2,22 @@
|
|||||||
"authorize": {
|
"authorize": {
|
||||||
"title": "Authorize application",
|
"title": "Authorize application",
|
||||||
"errorPage": "The authorization URL provided is invalid or malformed. Please forward this message to the developers of the application you came here from:",
|
"errorPage": "The authorization URL provided is invalid or malformed. Please forward this message to the developers of the application you came here from:",
|
||||||
"authorize": "Authorize",
|
"authorize": "Authorize the application",
|
||||||
"reject": "Reject",
|
"reject": "Reject",
|
||||||
|
"allowed": "This application will have access to..",
|
||||||
|
"disallowed": "This application will NOT have access to..",
|
||||||
"scope": {
|
"scope": {
|
||||||
"profile": "Username and display name",
|
"profile": "Your username and display name",
|
||||||
"email": "Email address",
|
"email": "Your email address",
|
||||||
"picture": "Profile picture",
|
"picture": "Your profile picture",
|
||||||
"account": "Password and other account settings",
|
"account": "Changing your password or other account settings",
|
||||||
"management": "Manage Icy Network on your behalf",
|
"management": "Manage Icy Network on your behalf",
|
||||||
"admin": "Commit administrative actions to the extent of your user privileges"
|
"admin": "Commit administrative actions to the extent of your user privileges"
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"website": "Website",
|
||||||
|
"privacy": "Privacy policy",
|
||||||
|
"terms": "Terms of Service"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
47
src/lib/server/oauth2/controller/bearer.ts
Normal file
47
src/lib/server/oauth2/controller/bearer.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { AccessDenied } from '../error';
|
||||||
|
import { OAuth2AccessTokens, type OAuth2AccessToken } from '../model';
|
||||||
|
|
||||||
|
export class OAuth2BearerController {
|
||||||
|
static bearerFromRequest = async (
|
||||||
|
request: Request,
|
||||||
|
url: URL
|
||||||
|
): Promise<OAuth2AccessToken | undefined> => {
|
||||||
|
let token = null;
|
||||||
|
|
||||||
|
// Look for token in header
|
||||||
|
if (request.headers.has('authorization')) {
|
||||||
|
const pieces = (request.headers.get('authorization') as string).split(' ', 2);
|
||||||
|
|
||||||
|
// Check authorization header
|
||||||
|
if (!pieces || pieces.length !== 2) {
|
||||||
|
throw new AccessDenied('Wrong authorization header');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only bearer auth is supported
|
||||||
|
if (pieces[0].toLowerCase() !== 'bearer') {
|
||||||
|
throw new AccessDenied('Unsupported authorization method in header');
|
||||||
|
}
|
||||||
|
|
||||||
|
token = pieces[1];
|
||||||
|
} else if (request.headers.has('x-access-token')) {
|
||||||
|
token = request.headers.get('x-access-token');
|
||||||
|
} else if (url?.searchParams.has('access_token')) {
|
||||||
|
token = url.searchParams.get('access_token') as string;
|
||||||
|
} else {
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
if (!body.access_token) {
|
||||||
|
throw new AccessDenied('Bearer token not found');
|
||||||
|
}
|
||||||
|
token = body.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to fetch access token
|
||||||
|
const object = await OAuth2AccessTokens.fetchByToken(token);
|
||||||
|
if (!object) {
|
||||||
|
throw new AccessDenied('Token not found or has expired');
|
||||||
|
} else if (!OAuth2AccessTokens.checkTTL(object)) {
|
||||||
|
throw new AccessDenied('Token is expired');
|
||||||
|
}
|
||||||
|
return object;
|
||||||
|
};
|
||||||
|
}
|
@ -1,3 +1,4 @@
|
|||||||
export * from './authorization';
|
export * from './authorization';
|
||||||
export * from './introspection';
|
export * from './introspection';
|
||||||
export * from './token';
|
export * from './token';
|
||||||
|
export * from './bearer';
|
||||||
|
@ -15,9 +15,9 @@ import type { OAuth2TokenResponse } from '../../response';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Issue an access token by authorization code
|
* Issue an access token by authorization code
|
||||||
* @param oauth2 - OAuth2 instance
|
|
||||||
* @param client - OAuth2 client
|
* @param client - OAuth2 client
|
||||||
* @param providedCode - Authorization code
|
* @param providedCode - Authorization code
|
||||||
|
* @param codeVerifier - PCKE check verifier
|
||||||
* @returns Access token.
|
* @returns Access token.
|
||||||
*/
|
*/
|
||||||
export async function authorizationCode(
|
export async function authorizationCode(
|
||||||
|
@ -5,7 +5,6 @@ import type { OAuth2TokenResponse } from '../../response';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Issue client access token
|
* Issue client access token
|
||||||
* @param oauth2 - OAuth2 instance
|
|
||||||
* @param client - Client
|
* @param client - Client
|
||||||
* @param wantScope - Requested scopes
|
* @param wantScope - Requested scopes
|
||||||
* @returns Access token
|
* @returns Access token
|
||||||
|
@ -12,7 +12,6 @@ import type { OAuth2TokenResponse } from '../../response';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a new access token from a refresh token. Scope change may not be requested.
|
* Get a new access token from a refresh token. Scope change may not be requested.
|
||||||
* @param oauth2 - OAuth2 instance
|
|
||||||
* @param client - OAuth2 client
|
* @param client - OAuth2 client
|
||||||
* @param providedToken - Refresh token
|
* @param providedToken - Refresh token
|
||||||
* @returns Access token
|
* @returns Access token
|
||||||
|
@ -10,7 +10,6 @@ import { and, eq, 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';
|
||||||
import { AccessDenied } from '../error';
|
|
||||||
|
|
||||||
export type CodeChallengeMethod = 'plain' | 'S256';
|
export type CodeChallengeMethod = 'plain' | 'S256';
|
||||||
|
|
||||||
@ -254,46 +253,6 @@ export class OAuth2AccessTokens {
|
|||||||
clientIdPub: find.o_auth2_client.client_id
|
clientIdPub: find.o_auth2_client.client_id
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getFromRequest(request: Request, url?: URL): Promise<OAuth2AccessToken | undefined> {
|
|
||||||
let token = null;
|
|
||||||
|
|
||||||
// Look for token in header
|
|
||||||
if (request.headers.has('authorization')) {
|
|
||||||
const pieces = (request.headers.get('authorization') as string).split(' ', 2);
|
|
||||||
|
|
||||||
// Check authorization header
|
|
||||||
if (!pieces || pieces.length !== 2) {
|
|
||||||
throw new AccessDenied('Wrong authorization header');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only bearer auth is supported
|
|
||||||
if (pieces[0].toLowerCase() !== 'bearer') {
|
|
||||||
throw new AccessDenied('Unsupported authorization method in header');
|
|
||||||
}
|
|
||||||
|
|
||||||
token = pieces[1];
|
|
||||||
} else if (request.headers.has('x-access-token')) {
|
|
||||||
token = request.headers.get('x-access-token');
|
|
||||||
} else if (url?.searchParams.has('access_token')) {
|
|
||||||
token = url.searchParams.get('access_token') as string;
|
|
||||||
} else {
|
|
||||||
const body = await request.json().catch(() => ({}));
|
|
||||||
if (!body.access_token) {
|
|
||||||
throw new AccessDenied('Bearer token not found');
|
|
||||||
}
|
|
||||||
token = body.access_token;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to fetch access token
|
|
||||||
const object = await OAuth2AccessTokens.fetchByToken(token);
|
|
||||||
if (!object) {
|
|
||||||
throw new AccessDenied('Token not found or has expired');
|
|
||||||
} else if (!OAuth2AccessTokens.checkTTL(object)) {
|
|
||||||
throw new AccessDenied('Token is expired');
|
|
||||||
}
|
|
||||||
return object;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OAuth2RefreshTokens {
|
export class OAuth2RefreshTokens {
|
||||||
|
@ -155,7 +155,7 @@
|
|||||||
<ViewColumn slot="side">
|
<ViewColumn slot="side">
|
||||||
<div>
|
<div>
|
||||||
<h3>{$t('account.avatar.title')}</h3>
|
<h3>{$t('account.avatar.title')}</h3>
|
||||||
<AvatarCard user={data.user} cacheBust={data.updateRef}>
|
<AvatarCard src={`/api/avatar/${data.user.uuid}?t=${data.updateRef}`} alt={data.user.name}>
|
||||||
<ViewColumn>
|
<ViewColumn>
|
||||||
<Button variant="primary" on:click={() => ($showAvatarModal = true)}
|
<Button variant="primary" on:click={() => ($showAvatarModal = true)}
|
||||||
>{$t('account.avatar.change')}</Button
|
>{$t('account.avatar.change')}</Button
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { PUBLIC_URL } from '$env/static/public';
|
import { PUBLIC_URL } from '$env/static/public';
|
||||||
import { ApiUtils } from '$lib/server/api-utils.js';
|
import { ApiUtils } from '$lib/server/api-utils.js';
|
||||||
import type { User } from '$lib/server/drizzle/schema.js';
|
import type { User } from '$lib/server/drizzle/schema.js';
|
||||||
|
import { OAuth2BearerController } from '$lib/server/oauth2/controller/bearer.js';
|
||||||
import { AccessDenied, OAuth2Error } from '$lib/server/oauth2/error.js';
|
import { AccessDenied, OAuth2Error } from '$lib/server/oauth2/error.js';
|
||||||
import { OAuth2Clients } from '$lib/server/oauth2/model/client.js';
|
import { OAuth2Clients } from '$lib/server/oauth2/model/client.js';
|
||||||
import { OAuth2AccessTokens } from '$lib/server/oauth2/model/tokens.js';
|
|
||||||
import { OAuth2Response } from '$lib/server/oauth2/response.js';
|
import { OAuth2Response } from '$lib/server/oauth2/response.js';
|
||||||
import { Users } from '$lib/server/users/index.js';
|
import { Users } from '$lib/server/users/index.js';
|
||||||
|
|
||||||
@ -17,7 +17,7 @@ export const GET = async ({ request, url, locals }) => {
|
|||||||
} else {
|
} else {
|
||||||
// From OAuth2 bearer token
|
// From OAuth2 bearer token
|
||||||
try {
|
try {
|
||||||
const token = await OAuth2AccessTokens.getFromRequest(request, url);
|
const token = await OAuth2BearerController.bearerFromRequest(request, url);
|
||||||
if (token?.userId) {
|
if (token?.userId) {
|
||||||
tokenScopes = OAuth2Clients.splitScope(token.scope || '');
|
tokenScopes = OAuth2Clients.splitScope(token.scope || '');
|
||||||
user = await Users.getById(token.userId);
|
user = await Users.getById(token.userId);
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { assets } from '$app/paths';
|
||||||
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/ColumnView.svelte';
|
import ColumnView from '$lib/components/ColumnView.svelte';
|
||||||
@ -12,9 +12,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<MainContainer>
|
<MainContainer>
|
||||||
<h1>{$t('common.siteName')}</h1>
|
|
||||||
|
|
||||||
{#if data.error}
|
{#if data.error}
|
||||||
|
<h1>{$t('common.siteName')}</h1>
|
||||||
<ColumnView>
|
<ColumnView>
|
||||||
<Alert type="error"
|
<Alert type="error"
|
||||||
>{$t('oauth2.authorize.errorPage')}<br /><br /><code
|
>{$t('oauth2.authorize.errorPage')}<br /><br /><code
|
||||||
@ -26,38 +25,167 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.client}
|
{#if data.client}
|
||||||
<h2>{$t('oauth2.authorize.title')}</h2>
|
<h1 class="title">{$t('common.siteName')}</h1>
|
||||||
{#if data.user}
|
<h2 class="title">{$t('oauth2.authorize.title')}</h2>
|
||||||
<AvatarCard user={data.user}>
|
|
||||||
<ColumnView>
|
|
||||||
<span class="user-display-name">{data.user.name}</span>
|
|
||||||
<span class="user-user-name">@{data.user.username}</span>
|
|
||||||
</ColumnView>
|
|
||||||
</AvatarCard>
|
|
||||||
{/if}
|
|
||||||
{data.client.title}
|
|
||||||
{data.client.description}
|
|
||||||
|
|
||||||
<div class="scope-list scope-list-allowed">
|
<div class="user-client-wrapper">
|
||||||
{#each data.client.allowedScopes as scope}
|
{#if data.user}
|
||||||
{$t(`oauth2.authorize.scope.${scope}`)}
|
<div class="card">
|
||||||
{/each}
|
<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={`${assets}/application.png`} 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">
|
||||||
|
{#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>
|
||||||
|
|
||||||
<div class="scope-list scope-list-disallowed">
|
<div class="scope">
|
||||||
{#each data.client.disallowedScopes as scope}
|
{#if data.client.allowedScopes?.length}
|
||||||
{$t(`oauth2.authorize.scope.${scope}`)}
|
<h3>{$t('oauth2.authorize.allowed')}</h3>
|
||||||
{/each}
|
<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>
|
||||||
|
|
||||||
<form action="" method="POST">
|
<div class="decision">
|
||||||
<input type="hidden" value="1" name="decision" />
|
<form action="" method="POST">
|
||||||
<Button type="submit" variant="primary">{$t('oauth2.authorize.authorize')}</Button>
|
<input type="hidden" value="1" name="decision" />
|
||||||
</form>
|
<Button type="submit" variant="primary">{$t('oauth2.authorize.authorize')}</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<form action="" method="POST">
|
<form action="" method="POST">
|
||||||
<input type="hidden" value="0" name="decision" />
|
<input type="hidden" value="0" name="decision" />
|
||||||
<Button type="submit" variant="link">{$t('oauth2.authorize.reject')}</Button>
|
<Button type="submit" variant="link">{$t('oauth2.authorize.reject')}</Button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.title {
|
||||||
|
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');
|
||||||
|
filter: invert();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
Loading…
Reference in New Issue
Block a user