Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions projects/api/src/contracts/oauth/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { authMetadata, builder } from '../_internal/builder.ts';
import { z } from '../_internal/z.ts';
import { clientSecretSchema } from './schema/request/clientSecretSchema.ts';
import { codeRequestSchema } from './schema/request/codeRequestSchema.ts';
import {
deviceTokenRequestSchema,
Expand All @@ -17,14 +18,19 @@ const authorizeQuerySchema = z.object({
client_id: z.string().optional(),
redirect_uri: z.string().optional(),
state: z.string().optional(),
code_challenge: z.string().optional(),
code_challenge_method: z.literal('S256').optional(),
});

const revokeRequestSchema = z.object({
token: z.string(),
client_id: z.string(),
client_secret: z.string(),
client_secret: clientSecretSchema,
});

const CLIENT_SECRET_NOTE = `> ### Note
> _\`client_secret\` is deprecated in favor of [PKCE](https://developer.trakt.tv/docs/pkce). It is optional, and should only be sent from your own server, never from a website, mobile app, or desktop app._`;

const device = builder.router({
code: {
summary: 'Generate new device codes',
Expand All @@ -38,7 +44,8 @@ You might consider generating a QR code for the user to easily scan on their mob
#### JSON POST Data
| Key | Type | Value |
|---|---|---|
| \`client_id\` * | string | Get this from your app settings. |`,
| \`client_id\` * | string | Get this from your app settings. |
`,
method: 'POST',
path: '/code',
body: codeRequestSchema,
Expand All @@ -58,7 +65,6 @@ When you receive a \`200\` success response, save the \`access_token\` so your a
|---|---|---|
| \`code\` * | string | \`device_code\` from the initial method. |
| \`client_id\` * | string | Get this from your app settings. |
| \`client_secret\` * | string | Get this from your app settings. |

#### Status Codes
This method will send various HTTP status codes that you should handle accordingly.
Expand Down Expand Up @@ -126,7 +132,18 @@ When building the authorization URL, you can optionally include the following qu
| Parameter | Value | Description |
|---|---|---|
| \`signup\` | \`true\` | Prefer the account sign up page to be the default. |
| \`prompt\` | \`login\` | Force the user to sign in and authorize your app. |`,
| \`prompt\` | \`login\` | Force the user to sign in and authorize your app. |

#### PKCE

[PKCE](https://developer.trakt.tv/docs/pkce) is the recommended way to sign users in. Send these parameters, then send the matching \`code_verifier\` instead of a \`client_secret\` when you exchange the code.

| Parameter | Value | Description |
|---|---|---|
| \`code_challenge\` | string | The base64url-encoded SHA-256 hash of your \`code_verifier\`. |
| \`code_challenge_method\` | \`S256\` | The only supported method. |

${CLIENT_SECRET_NOTE}`,
path: '/authorize',
method: 'GET',
query: authorizeQuerySchema,
Expand All @@ -140,6 +157,10 @@ When building the authorization URL, you can optionally include the following qu
description:
`Exchange an OAuth authorization code or refresh token for an access token.

If you started the flow with [PKCE](https://developer.trakt.tv/docs/pkce), send the \`code_verifier\`.

${CLIENT_SECRET_NOTE}

#### Refreshing Tokens

Refresh tokens are single-use. Each successful refresh returns a new \`refresh_token\` and invalidates the previous one. Store the new \`access_token\` and \`refresh_token\` from the response before the next refresh.
Expand All @@ -166,7 +187,8 @@ If the exchange cannot be completed, this endpoint returns a \`400\` response wi
|---|---|---|
| \`token\` * | string | A valid OAuth \`access_token\`. |
| \`client_id\` * | string | Get this from your app settings. |
| \`client_secret\` * | string | Get this from your app settings. |`,

${CLIENT_SECRET_NOTE}`,
path: '/revoke',
method: 'POST',
body: revokeRequestSchema,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { z } from '../../../_internal/z.ts';

/** Zod schema for the deprecated, optional `client_secret` of OAuth requests. */
export const clientSecretSchema = z.string({
description:
'Deprecated. Only send it from your own server, never from a website, mobile app, or desktop app.',
}).openapi({ deprecated: true }).optional();
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from '../../../_internal/z.ts';
import { clientSecretSchema } from './clientSecretSchema.ts';

/** Zod schema for the device token request. */
export const deviceTokenRequestSchema = z.object({
Expand All @@ -10,8 +11,5 @@ export const deviceTokenRequestSchema = z.object({
description: `The client ID of the application.
You can find it in the application details here: https://app.trakt.tv/settings/apps`,
}),
client_secret: z.string({
description: `The client secret of the application.
You can find it in the application details here: https://app.trakt.tv/settings/apps`,
}),
client_secret: clientSecretSchema,
});
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { z } from '../../../_internal/z.ts';
import { clientSecretSchema } from './clientSecretSchema.ts';

/** Zod schema for token base. */
export const tokenBaseSchema = z.object({
client_id: z.string({
description: `The client ID of the application.
You can find it in the application details here: https://app.trakt.tv/settings/apps`,
}),
client_secret: z.string({
description: `The client secret of the application.
You can find it in the application details here: https://app.trakt.tv/settings/apps`,
}),
client_secret: clientSecretSchema,
redirect_uri: z.string({
description: 'URI specified in your app settings.',
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@ export const tokenRequestSchema = tokenBaseSchema.extend({
grant_type: z.string({
description: 'Defines how an access token is obtained.',
}),
code_verifier: z.string({
description:
'The PKCE code verifier whose SHA-256 hash was sent as `code_challenge`. Required when the flow started with a `code_challenge`. See https://developer.trakt.tv/docs/pkce',
}).optional(),
Comment thread
kevincador marked this conversation as resolved.
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<script lang="ts">
import type { ApplicationFormProps } from "./ApplicationFormProps.ts";
import { parseApplication } from "./parseApplication.ts";
import RedirectUriWarning from "./RedirectUriWarning.svelte";
import { unsafeRedirectUris } from "./unsafeRedirectUris.ts";

const {
app,
Expand All @@ -17,6 +19,7 @@
let redirects = $state(initial?.redirect_uri ?? "");
let origins = $state(initial?.origins.join("\n") ?? "");
let error = $state("");
const unsafeRedirects = $derived(unsafeRedirectUris(redirects));

function submit(event: SubmitEvent) {
event.preventDefault();
Expand Down Expand Up @@ -60,15 +63,16 @@

<label
>Redirect URIs <span
>One per line. Custom schemes and the OAuth out-of-band URI are
supported.</span
>One per line. Use https:// only; on mobile, back it with Universal
Links or verified App Links.</span
><textarea
bind:value={redirects}
required
rows="4"
placeholder="https://example.com/callback"
spellcheck="false"></textarea></label
>
<RedirectUriWarning uris={unsafeRedirects} />

<label
>Allowed origins <span
Expand Down
113 changes: 80 additions & 33 deletions projects/developer/src/lib/features/apps/Applications.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@
import type { DeveloperProfile } from "./DeveloperProfile.ts";
import { completeGithubConnect } from "./completeGithubConnect.ts";
import { githubConnectUrl } from "./githubConnectUrl.ts";
import { GITHUB_CLIENT_ID } from "./GITHUB_CLIENT_ID.ts";
import { isGithubCallback } from "./isGithubCallback.ts";
import type { GithubConnectIntent } from "./GithubConnectIntent.ts";
import PkceNotice from "./PkceNotice.svelte";
import RedirectUriWarning from "./RedirectUriWarning.svelte";
import { unsafeRedirectUris } from "./unsafeRedirectUris.ts";

const { slot, mode, appId, appName }: ApplicationsProps = $props();
let loading = $state(true);
Expand Down Expand Up @@ -67,17 +71,8 @@
]
: [],
);
const credentials = $derived(
selected
? [
{ label: "Client ID", value: selected.client_id, secret: false },
{
label: "Client Secret",
value: selected.client_secret,
secret: true,
},
]
: [],
const unsafeRedirects = $derived(
selected ? unsafeRedirectUris(selected.redirect_uri) : [],
);
let busy = $state(false);
let error = $state("");
Expand Down Expand Up @@ -155,7 +150,11 @@
}

function connect(intent: GithubConnectIntent) {
globalThis.location.assign(githubConnectUrl(intent));
if (!GITHUB_CLIENT_ID) return;

globalThis.location.assign(
githubConnectUrl({ intent, clientId: GITHUB_CLIENT_ID }),
);
}

async function unlink() {
Expand Down Expand Up @@ -299,6 +298,7 @@
{profile}
{busy}
error={railError}
canConnect={!!GITHUB_CLIENT_ID}
onConnect={connect}
onUnlink={unlink}
/>
Expand All @@ -311,7 +311,7 @@
? "Create your first app"
: "Create app"}</strong
>{#if apps.length === 0}<small
>Register an app to get your Client ID and Client Secret.</small
>Register an app to get your Client ID.</small
>{/if}</a
>{:else}<div class="create-tile locked">
<svg
Expand Down Expand Up @@ -373,6 +373,8 @@
/>
</section>
{:else if selected}
<RedirectUriWarning uris={unsafeRedirects} />

<div class="detail-grid">
<section class="panel">
{#each details as field (field.label)}
Expand Down Expand Up @@ -403,35 +405,61 @@
</div>
{/each}

{#each credentials as field (field.label)}
<div class="credential">
<strong>{field.label}</strong>
<div class="credential">
<strong>Client ID</strong>
<div class="credential-value">
<code>{selected.client_id}</code>
<div class="actions">
<button
aria-label="Copy Client ID"
onclick={() => copy(selected.client_id, "Client ID")}
>Copy</button
>
</div>
</div>
</div>

<PkceNotice hasSecret={!!selected.client_secret} />

{#if selected.client_secret}<div class="credential">
<strong
>Client Secret <span class="badge" data-variant="deprecated"
>Deprecated</span
></strong
>
<div class="credential-value">
<code
>{field.secret && !reveal
? "••••••••••••••••••••••••"
: field.value}</code
>{reveal
? selected.client_secret
: "••••••••••••••••••••••••"}</code
>
<div class="actions">
{#if field.secret}<button
aria-label={reveal
? "Hide Client Secret"
: "Reveal Client Secret"}
onclick={() => (reveal = !reveal)}
>{reveal ? "Hide" : "Reveal"}</button
>{/if}<button
aria-label={`Copy ${field.label}`}
onclick={() => copy(field.value, field.label)}>Copy</button
<button
aria-label={reveal
? "Hide Client Secret"
: "Reveal Client Secret"}
onclick={() => (reveal = !reveal)}
>{reveal ? "Hide" : "Reveal"}</button
><button
aria-label="Copy Client Secret"
onclick={() =>
copy(selected.client_secret ?? "", "Client Secret")}
>Copy</button
>
</div>
</div>
</div>
{/each}

<p class="muted">
Keep your Client Secret private. Never include it in public code or
client-side apps.
</p>
<p class="muted">
Keep your Client Secret on your server. Never include it in public
code, websites, mobile apps, or desktop apps.
</p>{:else}<div class="credential is-empty">
<strong>Client Secret</strong>
<p class="secret-placeholder">
Not issued. This app signs users in with PKCE, so there is no
secret to keep safe.
</p>
</div>{/if}
</section>
<aside class="panel">
<h2>App details</h2>
Expand Down Expand Up @@ -658,6 +686,12 @@
font-size: 24px;
}

.badge[data-variant="deprecated"] {
margin-inline-start: var(--gap-xs);
color: var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 14%, transparent);
}

.badge {
font-size: 10px;
color: var(--color-muted);
Expand Down Expand Up @@ -725,6 +759,19 @@
padding-top: 0;
}

.credential.is-empty {
border-bottom: 0;
padding-bottom: 0;
}

.secret-placeholder {
margin: 0;
padding: var(--gap-s) var(--gap-m);
border: var(--border-thickness-xxs) dashed var(--color-border-strong);
border-radius: var(--radius-control);
font-size: 12px;
}

.field-value {
flex: 1;
min-width: 0;
Expand Down
Loading
Loading