feat(ui): wire resolvespec oauth login flow
Some checks failed
CI / build-and-test (push) Failing after -32m3s
Some checks failed
CI / build-and-test (push) Failing after -32m3s
This commit is contained in:
@@ -1,19 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { getApiURL } from '@warkypublic/svelix';
|
||||||
import {
|
import {
|
||||||
Former,
|
buildOAuthAuthorizationURL,
|
||||||
FormerRestHeadSpecAPI,
|
ensureApiURL,
|
||||||
InlineWrapper,
|
exchangeOAuthCode,
|
||||||
PasswordInputCtrl,
|
GlobalStateStore,
|
||||||
TextInputCtrl,
|
setCurrentPath
|
||||||
getApiURL
|
} from './shellState';
|
||||||
} from '@warkypublic/svelix';
|
|
||||||
import { ensureApiURL, GlobalStateStore } from './shellState';
|
|
||||||
|
|
||||||
type LoginResult = {
|
|
||||||
token?: string;
|
|
||||||
username?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AccessEntry = {
|
type AccessEntry = {
|
||||||
key_id: string;
|
key_id: string;
|
||||||
@@ -68,12 +62,10 @@
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
let values = $state<{ username: string; password: string }>({
|
|
||||||
username: '',
|
|
||||||
password: ''
|
|
||||||
});
|
|
||||||
let authMessage = $state('');
|
let authMessage = $state('');
|
||||||
let authError = $state('');
|
let authError = $state('');
|
||||||
|
let authBusy = $state(false);
|
||||||
|
let callbackBusy = $state(false);
|
||||||
let data = $state<StatusResponse | null>(null);
|
let data = $state<StatusResponse | null>(null);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
@@ -81,29 +73,57 @@
|
|||||||
|
|
||||||
ensureApiURL(import.meta.env.VITE_API_URL);
|
ensureApiURL(import.meta.env.VITE_API_URL);
|
||||||
|
|
||||||
const onAPICall = $derived(
|
|
||||||
FormerRestHeadSpecAPI({
|
|
||||||
url: `${getApiURL()}/login`
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const isLoggedIn = $derived(GlobalStateStore.isLoggedIn());
|
const isLoggedIn = $derived(GlobalStateStore.isLoggedIn());
|
||||||
|
const currentPath = $derived(typeof window !== 'undefined' ? window.location.pathname : '/');
|
||||||
|
const isOAuthCallback = $derived(currentPath === '/oauth/callback');
|
||||||
|
const oauthAuthorizeURL = $derived(`${getApiURL()}/oauth/authorize`);
|
||||||
|
|
||||||
async function handleLogin(value: LoginResult): Promise<void> {
|
async function startOAuthLogin(): Promise<void> {
|
||||||
const token = value?.token?.trim();
|
authBusy = true;
|
||||||
if (!token) {
|
|
||||||
authError = 'Login succeeded but no token was returned by the API.';
|
|
||||||
authMessage = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await GlobalStateStore.getState().login(token, {
|
|
||||||
username: value?.username ?? values.username
|
|
||||||
});
|
|
||||||
|
|
||||||
authMessage = `Welcome back ${value?.username ?? values.username ?? 'operator'}.`;
|
|
||||||
authError = '';
|
authError = '';
|
||||||
await loadStatus();
|
authMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authorizationURL = await buildOAuthAuthorizationURL();
|
||||||
|
window.location.assign(authorizationURL);
|
||||||
|
} catch (err) {
|
||||||
|
authError = err instanceof Error ? err.message : 'Failed to start OAuth login.';
|
||||||
|
} finally {
|
||||||
|
authBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishOAuthLogin(): Promise<void> {
|
||||||
|
callbackBusy = true;
|
||||||
|
authError = '';
|
||||||
|
authMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const code = params.get('code');
|
||||||
|
const returnedState = params.get('state');
|
||||||
|
const oauthError = params.get('error');
|
||||||
|
|
||||||
|
if (oauthError) {
|
||||||
|
throw new Error(`OAuth login failed: ${oauthError}`);
|
||||||
|
}
|
||||||
|
if (!code || !returnedState) {
|
||||||
|
throw new Error('OAuth callback is missing code or state.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await exchangeOAuthCode(code, returnedState);
|
||||||
|
await GlobalStateStore.getState().login(token, {
|
||||||
|
username: 'OAuth operator'
|
||||||
|
});
|
||||||
|
|
||||||
|
authMessage = 'OAuth login complete. Welcome back.';
|
||||||
|
window.history.replaceState({}, '', '/');
|
||||||
|
await loadStatus();
|
||||||
|
} catch (err) {
|
||||||
|
authError = err instanceof Error ? err.message : 'OAuth callback failed.';
|
||||||
|
} finally {
|
||||||
|
callbackBusy = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout(): Promise<void> {
|
async function logout(): Promise<void> {
|
||||||
@@ -134,6 +154,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
setCurrentPath(window.location.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOAuthCallback) {
|
||||||
|
await finishOAuthLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
await loadStatus();
|
await loadStatus();
|
||||||
}
|
}
|
||||||
@@ -153,10 +182,16 @@
|
|||||||
<span class="h-2 w-2 rounded-full bg-emerald-400"></span>
|
<span class="h-2 w-2 rounded-full bg-emerald-400"></span>
|
||||||
AMCS Control Interface
|
AMCS Control Interface
|
||||||
</div>
|
</div>
|
||||||
<h1 class="mt-6 text-4xl font-semibold tracking-tight text-white">Login</h1>
|
<h1 class="mt-6 text-4xl font-semibold tracking-tight text-white">
|
||||||
|
{#if isOAuthCallback}
|
||||||
|
Completing login
|
||||||
|
{:else}
|
||||||
|
Login
|
||||||
|
{/if}
|
||||||
|
</h1>
|
||||||
<p class="mt-3 max-w-2xl text-base leading-7 text-slate-300">
|
<p class="mt-3 max-w-2xl text-base leading-7 text-slate-300">
|
||||||
Origin-style operator access for the AMCS admin interface. ResolveSpec OAuth stays the auth brain;
|
Origin-style operator access for the AMCS admin interface. ResolveSpec OAuth is the front door now,
|
||||||
this shell just gives us the front door.
|
not the old login shortcut.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
||||||
@@ -166,66 +201,63 @@
|
|||||||
<p class="mt-2 text-sm text-slate-400">Projects are the first real admin screen in this rollout.</p>
|
<p class="mt-2 text-sm text-slate-400">Projects are the first real admin screen in this rollout.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">UI direction</p>
|
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">OAuth path</p>
|
||||||
<p class="mt-2 text-2xl font-semibold text-white">Origin-like</p>
|
<p class="mt-2 text-2xl font-semibold text-white">ResolveSpec</p>
|
||||||
<p class="mt-2 text-sm text-slate-400">Login and page structure mapped toward Origin patterns.</p>
|
<p class="mt-2 text-sm text-slate-400">Client registration, authorize, callback, token exchange.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-900 p-6 shadow-xl shadow-slate-950/30 sm:p-8">
|
<div class="rounded-3xl border border-white/10 bg-slate-900 p-6 shadow-xl shadow-slate-950/30 sm:p-8">
|
||||||
<h2 class="text-xl font-semibold text-white">Operator login</h2>
|
{#if isOAuthCallback}
|
||||||
<p class="mt-1 text-sm text-slate-400">Authenticate to access AMCS admin pages.</p>
|
<h2 class="text-xl font-semibold text-white">Authorizing operator session</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-400">
|
||||||
|
Finishing the ResolveSpec handshake and exchanging the returned code for an AMCS token.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6 rounded-2xl border border-cyan-400/20 bg-cyan-400/5 px-4 py-6 text-sm text-cyan-100">
|
||||||
<Former
|
{#if callbackBusy}
|
||||||
bind:values
|
Working the callback doohickey…
|
||||||
layout={{ buttonArea: 'none' }}
|
{:else if authError}
|
||||||
{onAPICall}
|
Callback failed. Fix the route or try the login run again.
|
||||||
onChange={(value) => {
|
{:else}
|
||||||
handleLogin(value as LoginResult);
|
Callback processed.
|
||||||
}}
|
{/if}
|
||||||
onError={(err) => {
|
|
||||||
authError = String(err || 'Failed to login. Please check your credentials and try again.');
|
|
||||||
authMessage = '';
|
|
||||||
}}
|
|
||||||
request="insert"
|
|
||||||
>
|
|
||||||
{#snippet children(state)}
|
|
||||||
<div class="space-y-4">
|
|
||||||
<InlineWrapper label="Email address" required value={state.values?.username ?? ''}>
|
|
||||||
<TextInputCtrl
|
|
||||||
name="username"
|
|
||||||
placeholder="operator@example.com"
|
|
||||||
value={state.values?.username ?? ''}
|
|
||||||
onchange={(v) => state.setState('values', { ...state.values, username: v })}
|
|
||||||
/>
|
|
||||||
</InlineWrapper>
|
|
||||||
|
|
||||||
<InlineWrapper label="Password" required value={state.values?.password ?? ''}>
|
|
||||||
<PasswordInputCtrl
|
|
||||||
name="password"
|
|
||||||
placeholder="Your password"
|
|
||||||
value={state.values?.password ?? ''}
|
|
||||||
onchange={(v) => state.setState('values', { ...state.values, password: v })}
|
|
||||||
/>
|
|
||||||
</InlineWrapper>
|
|
||||||
|
|
||||||
<button type="submit" class="inline-flex w-full items-center justify-center rounded-xl border border-cyan-300/20 bg-cyan-400/10 px-4 py-3 text-sm font-semibold text-cyan-100 transition hover:border-cyan-300/40 hover:bg-cyan-400/20">
|
|
||||||
Login
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if authError}
|
|
||||||
<p class="text-sm text-rose-300">{authError}</p>
|
|
||||||
{/if}
|
|
||||||
{#if authMessage}
|
|
||||||
<p class="text-sm text-emerald-300">{authMessage}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</Former>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{:else}
|
||||||
|
<h2 class="text-xl font-semibold text-white">Operator login</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-400">Authenticate through AMCS ResolveSpec OAuth endpoints.</p>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex w-full items-center justify-center rounded-xl border border-cyan-300/20 bg-cyan-400/10 px-4 py-3 text-sm font-semibold text-cyan-100 transition hover:border-cyan-300/40 hover:bg-cyan-400/20 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
onclick={startOAuthLogin}
|
||||||
|
disabled={authBusy}
|
||||||
|
>
|
||||||
|
{#if authBusy}Starting OAuth login…{:else}Login with ResolveSpec OAuth{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/5 p-4 text-sm text-slate-300">
|
||||||
|
<p class="font-semibold text-white">Routes in play</p>
|
||||||
|
<ul class="mt-3 space-y-2 text-slate-400">
|
||||||
|
<li>• discovery: <code class="text-cyan-100">/api/.well-known/oauth-authorization-server</code></li>
|
||||||
|
<li>• registration: <code class="text-cyan-100">/api/oauth/register</code></li>
|
||||||
|
<li>• authorize: <code class="text-cyan-100">{oauthAuthorizeURL}</code></li>
|
||||||
|
<li>• callback: <code class="text-cyan-100">/oauth/callback</code></li>
|
||||||
|
<li>• token: <code class="text-cyan-100">/api/oauth/token</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if authError}
|
||||||
|
<p class="text-sm text-rose-300">{authError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if authMessage}
|
||||||
|
<p class="text-sm text-emerald-300">{authMessage}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -23,6 +23,39 @@ const resolveApiURL = (envURL?: string): string => {
|
|||||||
|
|
||||||
export { GlobalStateStore };
|
export { GlobalStateStore };
|
||||||
|
|
||||||
|
export type OAuthClientRegistration = {
|
||||||
|
client_id: string;
|
||||||
|
client_name?: string;
|
||||||
|
redirect_uris?: string[];
|
||||||
|
grant_types?: string[];
|
||||||
|
response_types?: string[];
|
||||||
|
token_endpoint_auth_method?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OAuthServerMetadata = {
|
||||||
|
issuer: string;
|
||||||
|
authorization_endpoint: string;
|
||||||
|
token_endpoint: string;
|
||||||
|
registration_endpoint: string;
|
||||||
|
scopes_supported?: string[];
|
||||||
|
response_types_supported?: string[];
|
||||||
|
grant_types_supported?: string[];
|
||||||
|
token_endpoint_auth_methods_supported?: string[];
|
||||||
|
code_challenge_methods_supported?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OAuthSession = {
|
||||||
|
clientId: string;
|
||||||
|
redirectURI: string;
|
||||||
|
codeVerifier: string;
|
||||||
|
state: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OAUTH_SESSION_KEY = 'amcs.oauth.session';
|
||||||
|
const OAUTH_CLIENT_KEY = 'amcs.oauth.client';
|
||||||
|
const OAUTH_DEFAULT_SCOPE = 'mcp';
|
||||||
|
|
||||||
export function ensureApiURL(envURL?: string): string {
|
export function ensureApiURL(envURL?: string): string {
|
||||||
const resolved = resolveApiURL(envURL);
|
const resolved = resolveApiURL(envURL);
|
||||||
if (!resolved) return '';
|
if (!resolved) return '';
|
||||||
@@ -35,6 +68,67 @@ export function ensureApiURL(envURL?: string): string {
|
|||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPublicBaseURL(): string {
|
||||||
|
if (typeof window === 'undefined') return '';
|
||||||
|
return `${window.location.protocol}//${window.location.host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOAuthRedirectURI(): string {
|
||||||
|
const base = getPublicBaseURL();
|
||||||
|
return base ? `${base}/oauth/callback` : '/oauth/callback';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStorage(storageKey: string): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return window.localStorage.getItem(storageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStorage(storageKey: string, value: string): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.localStorage.setItem(storageKey, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStorage(storageKey: string): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.localStorage.removeItem(storageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readOAuthClient(): OAuthClientRegistration | null {
|
||||||
|
const raw = getStorage(OAUTH_CLIENT_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as OAuthClientRegistration;
|
||||||
|
} catch {
|
||||||
|
removeStorage(OAUTH_CLIENT_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveOAuthClient(client: OAuthClientRegistration): void {
|
||||||
|
setStorage(OAUTH_CLIENT_KEY, JSON.stringify(client));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readOAuthSession(): OAuthSession | null {
|
||||||
|
const raw = getStorage(OAUTH_SESSION_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as OAuthSession;
|
||||||
|
} catch {
|
||||||
|
removeStorage(OAUTH_SESSION_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveOAuthSession(session: OAuthSession): void {
|
||||||
|
setStorage(OAUTH_SESSION_KEY, JSON.stringify(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearOAuthSession(): void {
|
||||||
|
removeStorage(OAUTH_SESSION_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
export function setCurrentPath(pathname: string): void {
|
export function setCurrentPath(pathname: string): void {
|
||||||
const state = GlobalStateStore.getState();
|
const state = GlobalStateStore.getState();
|
||||||
const current = state.navigation.currentPage ?? {};
|
const current = state.navigation.currentPage ?? {};
|
||||||
@@ -44,3 +138,139 @@ export function setCurrentPath(pathname: string): void {
|
|||||||
path: pathname
|
path: pathname
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createRandomString(length = 48): string {
|
||||||
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||||
|
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
||||||
|
const bytes = new Uint8Array(length);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes, (byte) => alphabet[byte % alphabet.length]).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from({ length }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||||
|
let binary = '';
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
const chunkSize = 0x8000;
|
||||||
|
|
||||||
|
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||||||
|
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256(input: string): Promise<string> {
|
||||||
|
if (typeof crypto === 'undefined' || !crypto.subtle) {
|
||||||
|
throw new Error('Secure browser crypto is required for OAuth login.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new TextEncoder().encode(input);
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
return base64UrlEncode(digest);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOAuthMetadata(): Promise<OAuthServerMetadata> {
|
||||||
|
const apiURL = ensureApiURL();
|
||||||
|
const response = await fetch(`${apiURL}/.well-known/oauth-authorization-server`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load OAuth metadata (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as OAuthServerMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureOAuthClientRegistration(metadata: OAuthServerMetadata): Promise<OAuthClientRegistration> {
|
||||||
|
const redirectURI = getOAuthRedirectURI();
|
||||||
|
const existing = readOAuthClient();
|
||||||
|
if (existing?.client_id && existing.redirect_uris?.includes(redirectURI)) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(metadata.registration_endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_name: 'AMCS Admin UI',
|
||||||
|
redirect_uris: [redirectURI],
|
||||||
|
grant_types: ['authorization_code'],
|
||||||
|
response_types: ['code'],
|
||||||
|
token_endpoint_auth_method: 'none'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to register OAuth client (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = (await response.json()) as OAuthClientRegistration;
|
||||||
|
saveOAuthClient(client);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildOAuthAuthorizationURL(): Promise<string> {
|
||||||
|
const metadata = await fetchOAuthMetadata();
|
||||||
|
const client = await ensureOAuthClientRegistration(metadata);
|
||||||
|
const codeVerifier = createRandomString(96);
|
||||||
|
const codeChallenge = await sha256(codeVerifier);
|
||||||
|
const state = createRandomString(40);
|
||||||
|
const redirectURI = getOAuthRedirectURI();
|
||||||
|
|
||||||
|
saveOAuthSession({
|
||||||
|
clientId: client.client_id,
|
||||||
|
redirectURI,
|
||||||
|
codeVerifier,
|
||||||
|
state,
|
||||||
|
createdAt: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = new URL(metadata.authorization_endpoint);
|
||||||
|
url.searchParams.set('client_id', client.client_id);
|
||||||
|
url.searchParams.set('redirect_uri', redirectURI);
|
||||||
|
url.searchParams.set('response_type', 'code');
|
||||||
|
url.searchParams.set('scope', OAUTH_DEFAULT_SCOPE);
|
||||||
|
url.searchParams.set('state', state);
|
||||||
|
url.searchParams.set('code_challenge', codeChallenge);
|
||||||
|
url.searchParams.set('code_challenge_method', 'S256');
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeOAuthCode(code: string, returnedState: string): Promise<string> {
|
||||||
|
const session = readOAuthSession();
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('OAuth session is missing. Start login again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.state !== returnedState) {
|
||||||
|
throw new Error('OAuth state mismatch. Start login again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = await fetchOAuthMetadata();
|
||||||
|
const response = await fetch(metadata.token_endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: session.redirectURI,
|
||||||
|
client_id: session.clientId,
|
||||||
|
code_verifier: session.codeVerifier
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { access_token?: string; error?: string };
|
||||||
|
if (!response.ok || !payload.access_token) {
|
||||||
|
throw new Error(payload.error || `Token exchange failed (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearOAuthSession();
|
||||||
|
return payload.access_token;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user