mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
refactor(auth): serve one provider, and make it the email one
Signing in with a gaming account or with an SSH key could both bring a user into existence. That makes an account only as recoverable as the thing that created it, and gives one person as many accounts as they have gaming logins — neither of which is what an account is supposed to be now that verifying an address is what creates one. Both are unwired rather than deleted. The provider implementations stay where they are, because connecting a gaming account is still something this product does; it just does it from the API, against a user who already exists, which is a connection hanging off an identity rather than an identity of its own. The worker test followed: it exercised the two flows that are gone, and now covers the one that is left plus an assertion that the other two are not routed, so they cannot come back quietly.
This commit is contained in:
@@ -5,7 +5,6 @@ import { Redacted } from 'effect';
|
|||||||
import * as Effect from 'effect/Effect';
|
import * as Effect from 'effect/Effect';
|
||||||
|
|
||||||
const steamApiKey = Redacted.make(process.env.STEAM_API_KEY!);
|
const steamApiKey = Redacted.make(process.env.STEAM_API_KEY!);
|
||||||
const sshAuthKey = process.env.SSH_AUTH_KEY || 'dev-ssh-auth-key-change-in-prod';
|
|
||||||
const adminSharedSecret =
|
const adminSharedSecret =
|
||||||
process.env.ADMIN_SHARED_SECRET || 'dev-admin-shared-secret-change-in-prod';
|
process.env.ADMIN_SHARED_SECRET || 'dev-admin-shared-secret-change-in-prod';
|
||||||
|
|
||||||
@@ -41,11 +40,12 @@ export const Auth = Effect.gen(function* () {
|
|||||||
return yield* Cloudflare.Worker('auth', {
|
return yield* Cloudflare.Worker('auth', {
|
||||||
main: 'apps/auth/src/index.ts',
|
main: 'apps/auth/src/index.ts',
|
||||||
compatibility: { flags: ['nodejs_compat'] },
|
compatibility: { flags: ['nodejs_compat'] },
|
||||||
|
// No Steam or SSH settings: the issuer serves one provider, and it is
|
||||||
|
// the email one. Linking a Steam account is `apps/api`'s job and its
|
||||||
|
// key is bound there.
|
||||||
env: {
|
env: {
|
||||||
AuthStorage,
|
AuthStorage,
|
||||||
HYPERDRIVE: Database,
|
HYPERDRIVE: Database
|
||||||
STEAM_API_KEY: steamApiKey,
|
|
||||||
SSH_AUTH_KEY: sshAuthKey
|
|
||||||
},
|
},
|
||||||
...(isPermanent ? { observability: { enabled: true } } : {})
|
...(isPermanent ? { observability: { enabled: true } } : {})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import type { Hyperdrive, KVNamespace } from '@cloudflare/workers-types';
|
import type { Hyperdrive, KVNamespace } from '@cloudflare/workers-types';
|
||||||
import { issuer } from '@nestri/auth/index';
|
import { issuer } from '@nestri/auth/index';
|
||||||
import { CodeProvider } from '@nestri/auth/provider/code';
|
import { CodeProvider } from '@nestri/auth/provider/code';
|
||||||
import { SshProvider } from '@nestri/auth/provider/ssh';
|
|
||||||
import { SteamProvider } from '@nestri/auth/provider/steam';
|
|
||||||
import { CloudflareStorage } from '@nestri/auth/storage/cloudflare';
|
import { CloudflareStorage } from '@nestri/auth/storage/cloudflare';
|
||||||
import { CodeUI } from '@nestri/auth/ui/code';
|
import { CodeUI } from '@nestri/auth/ui/code';
|
||||||
import { Actor } from '@nestri/core/actor';
|
import { Actor } from '@nestri/core/actor';
|
||||||
import { subjects } from '@nestri/core/auth/subjects';
|
import { subjects } from '@nestri/core/auth/subjects';
|
||||||
import { Env } from '@nestri/core/env';
|
import { Env } from '@nestri/core/env';
|
||||||
import { Steam } from '@nestri/core/steam/index';
|
|
||||||
import { Team } from '@nestri/core/team/index';
|
import { Team } from '@nestri/core/team/index';
|
||||||
import { Identity } from '@nestri/core/user/identity';
|
import { Identity } from '@nestri/core/user/identity';
|
||||||
import { User } from '@nestri/core/user/index';
|
|
||||||
import { LinkedAccount } from '@nestri/core/user/linked-account';
|
import { LinkedAccount } from '@nestri/core/user/linked-account';
|
||||||
|
|
||||||
import { sendVerificationCode } from './email.js';
|
import { sendVerificationCode } from './email.js';
|
||||||
@@ -19,12 +15,10 @@ import { sendVerificationCode } from './email.js';
|
|||||||
type Env = {
|
type Env = {
|
||||||
AuthStorage: KVNamespace;
|
AuthStorage: KVNamespace;
|
||||||
HYPERDRIVE: Hyperdrive;
|
HYPERDRIVE: Hyperdrive;
|
||||||
STEAM_API_KEY: string;
|
|
||||||
SSH_AUTH_KEY: string;
|
|
||||||
EMAIL_SEND_URL?: string;
|
EMAIL_SEND_URL?: string;
|
||||||
EMAIL_API_KEY?: string;
|
EMAIL_API_KEY?: string;
|
||||||
EMAIL_FROM?: string;
|
EMAIL_FROM?: string;
|
||||||
NODE_ENV?: string;
|
EMAIL_DEV_LOG?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,10 +52,21 @@ export default {
|
|||||||
storage: CloudflareStorage({
|
storage: CloudflareStorage({
|
||||||
namespace: env.AuthStorage
|
namespace: env.AuthStorage
|
||||||
}),
|
}),
|
||||||
|
// One provider, on purpose.
|
||||||
|
//
|
||||||
|
// Verifying an email address is the only thing that brings an
|
||||||
|
// account into existence. Steam and SSH were sign-ins here as well,
|
||||||
|
// and both could mint a user from a persona or a key — which makes
|
||||||
|
// the account only as recoverable as the thing that made it, and
|
||||||
|
// gives one person as many accounts as they have gaming logins.
|
||||||
|
//
|
||||||
|
// They are unwired rather than deleted: the providers still exist
|
||||||
|
// under `packages/auth/src/provider/`, because connecting a Steam
|
||||||
|
// account is something this product still does. It does it from
|
||||||
|
// `apps/api`'s `POST /steam/link`, against a user who already
|
||||||
|
// exists — which is a connection hanging off an identity, and not
|
||||||
|
// an identity of its own. ref(d-0048)
|
||||||
providers: {
|
providers: {
|
||||||
// Verifying an email address is what creates an account. It is
|
|
||||||
// listed first because it is the only branch below that is
|
|
||||||
// allowed to bring a person into existence. ref(d-0048)
|
|
||||||
code: CodeProvider({
|
code: CodeProvider({
|
||||||
// The UI, with delivery replaced. `CodeUI`'s own hook cannot
|
// The UI, with delivery replaced. `CodeUI`'s own hook cannot
|
||||||
// report a bad address back to the screen — it returns
|
// report a bad address back to the screen — it returns
|
||||||
@@ -78,9 +83,7 @@ export default {
|
|||||||
}
|
}
|
||||||
await sendVerificationCode(env, email, code);
|
await sendVerificationCode(env, email, code);
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
steam: SteamProvider(),
|
|
||||||
ssh: SshProvider({ sshAuthKey: env.SSH_AUTH_KEY })
|
|
||||||
},
|
},
|
||||||
async success(context, response) {
|
async success(context, response) {
|
||||||
if (response.provider === 'code') {
|
if (response.provider === 'code') {
|
||||||
@@ -99,65 +102,6 @@ export default {
|
|||||||
return context.subject('user', { userID, linkedAccountID });
|
return context.subject('user', { userID, linkedAccountID });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.provider === 'steam') {
|
|
||||||
const { steamid } = response;
|
|
||||||
|
|
||||||
// Signing in with Steam resolves an account; it never
|
|
||||||
// creates one. A Steam account is something a person
|
|
||||||
// attaches to an account they already have, so losing it
|
|
||||||
// costs them a link and not everything they own. Accounts
|
|
||||||
// that predate the rule already have the link this finds,
|
|
||||||
// so they keep working unchanged. ref(d-0048)
|
|
||||||
const { userID, linkedAccountID } = await Identity.resolveSteamLogin({
|
|
||||||
steamId: steamid
|
|
||||||
});
|
|
||||||
|
|
||||||
// The persona is refreshed on the way through, because this
|
|
||||||
// is the only moment the current one is in hand.
|
|
||||||
const player = await steamProfile(env.STEAM_API_KEY, steamid);
|
|
||||||
if (player) {
|
|
||||||
await LinkedAccount.updateProfile({ id: linkedAccountID, profile: player });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await User.fromID(userID);
|
|
||||||
await Actor.with({ type: 'user', properties: { userID, linkedAccountID } }, () =>
|
|
||||||
Team.ensurePersonal({
|
|
||||||
displayName: user?.name || (player?.personaname as string) || 'Player'
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return context.subject('user', {
|
|
||||||
userID,
|
|
||||||
linkedAccountID
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.provider === 'ssh') {
|
|
||||||
const { fingerprint, steamId, username, profile } = response;
|
|
||||||
const { userID, linkedAccountID } = await Steam.resolveSshIdentity({
|
|
||||||
fingerprint,
|
|
||||||
steamId,
|
|
||||||
username,
|
|
||||||
profile
|
|
||||||
});
|
|
||||||
|
|
||||||
// Same reason as the branch above. The SSH path creates
|
|
||||||
// users too, so leaving it out would give a host registered
|
|
||||||
// from `nessh` nowhere to live.
|
|
||||||
await Actor.with({ type: 'user', properties: { userID, linkedAccountID } }, () =>
|
|
||||||
// `username` is optional on the SSH path — a key can arrive
|
|
||||||
// before a persona does. The slug only has to be derivable,
|
|
||||||
// not pretty, and a rename is a later problem.
|
|
||||||
Team.ensurePersonal({ displayName: username ?? 'Player' })
|
|
||||||
);
|
|
||||||
|
|
||||||
return context.subject('user', {
|
|
||||||
userID,
|
|
||||||
linkedAccountID,
|
|
||||||
fingerprint
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Unknown provider');
|
throw new Error('Unknown provider');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -165,24 +109,3 @@ export default {
|
|||||||
return inner.fetch(request, env, ctx);
|
return inner.fetch(request, env, ctx);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The current persona for a Steam account, or null if Steam did not answer. */
|
|
||||||
async function steamProfile(
|
|
||||||
apiKey: string,
|
|
||||||
steamid: string
|
|
||||||
): Promise<Record<string, unknown> | null> {
|
|
||||||
try {
|
|
||||||
const profileUrl = new URL('https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/');
|
|
||||||
profileUrl.searchParams.set('key', apiKey);
|
|
||||||
profileUrl.searchParams.set('steamids', steamid);
|
|
||||||
|
|
||||||
const res = await fetch(profileUrl.toString());
|
|
||||||
const data = (await res.json()) as {
|
|
||||||
response?: { players?: Array<Record<string, unknown>> };
|
|
||||||
};
|
|
||||||
return data?.response?.players?.[0] ?? null;
|
|
||||||
} catch {
|
|
||||||
// A stale display name is not a reason to refuse a sign-in.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,125 +1,148 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
import { createClient } from '@nestri/auth/client';
|
import { createClient } from '@nestri/auth/client';
|
||||||
import { issuer } from '@nestri/auth/index';
|
import { issuer } from '@nestri/auth/index';
|
||||||
import { SshProvider } from '@nestri/auth/provider/ssh';
|
import { CodeProvider } from '@nestri/auth/provider/code';
|
||||||
import { SteamProvider } from '@nestri/auth/provider/steam';
|
|
||||||
import { MemoryStorage } from '@nestri/auth/storage/memory';
|
import { MemoryStorage } from '@nestri/auth/storage/memory';
|
||||||
|
import { CodeUI } from '@nestri/auth/ui/code';
|
||||||
import { subjects } from '@nestri/core/auth/subjects';
|
import { subjects } from '@nestri/core/auth/subjects';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The issuer the worker builds, with the database taken out.
|
||||||
|
*
|
||||||
|
* The provider list is the load-bearing part and is the same one
|
||||||
|
* `apps/auth/src/index.ts` passes: one entry, `code`. `success` is a stub
|
||||||
|
* because what the real one does — resolve an address to a user and give it a
|
||||||
|
* team — is core's behaviour and is held by core's own tests. What this file
|
||||||
|
* holds is the shape of the issuer around it.
|
||||||
|
*/
|
||||||
|
let lastCode = '';
|
||||||
const storage = MemoryStorage();
|
const storage = MemoryStorage();
|
||||||
|
|
||||||
const auth = issuer({
|
const auth = issuer({
|
||||||
subjects,
|
subjects,
|
||||||
storage,
|
storage,
|
||||||
allow: async () => true,
|
allow: async () => true,
|
||||||
providers: {
|
providers: {
|
||||||
steam: SteamProvider(),
|
code: CodeProvider({
|
||||||
ssh: SshProvider({ sshAuthKey: 'test-ssh-key' })
|
...CodeUI({ copy: { code_info: 'test' }, sendCode: async () => {} }),
|
||||||
|
sendCode: async (_claims, code) => {
|
||||||
|
lastCode = code;
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
async success(context, response) {
|
async success(context, response) {
|
||||||
if (response.provider === 'steam') {
|
if (response.provider === 'code') {
|
||||||
return context.subject('user', {
|
return context.subject('user', {
|
||||||
userID: 'usr_test123',
|
userID: 'usr_test123',
|
||||||
linkedAccountID: 'lac_test456'
|
linkedAccountID: ''
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (response.provider === 'ssh') {
|
throw new Error('Unknown provider');
|
||||||
return context.subject('user', {
|
|
||||||
userID: 'usr_test123',
|
|
||||||
linkedAccountID: 'lac_test456',
|
|
||||||
fingerprint: response.fingerprint
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw new Error('unknown provider');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
/**
|
||||||
globalThis.fetch = mock(async (input: string | URL | Request, _init?: RequestInit) => {
|
* Signing in with a gaming account or a key is gone, and this is the assertion
|
||||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
* that keeps it gone.
|
||||||
|
*
|
||||||
if (url.includes('steamcommunity.com/openid/login')) {
|
* Both used to be providers here and both could bring a user into existence
|
||||||
return new Response('ns:http://specs.openid.net/auth/2.0\nis_valid:true\n', { status: 200 });
|
* from something that is not an address, which is the shape the account model
|
||||||
}
|
* no longer has. The provider implementations still exist and can be wired
|
||||||
|
* back; what must not happen quietly is them becoming reachable again.
|
||||||
if (url.includes('api.steampowered.com')) {
|
*/
|
||||||
return new Response(
|
describe('what the issuer serves', () => {
|
||||||
JSON.stringify({
|
test('there is no sign-in with a gaming account', async () => {
|
||||||
response: {
|
|
||||||
players: [
|
|
||||||
{
|
|
||||||
personaname: 'TestPlayer',
|
|
||||||
avatarfull:
|
|
||||||
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb.jpg',
|
|
||||||
steamid: '76561197960287956'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ status: 200 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response('not found', { status: 404 });
|
|
||||||
}) as unknown as typeof fetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = fetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Steam auth flow', () => {
|
|
||||||
test('authorize redirects to Steam OpenID', async () => {
|
|
||||||
const response = await auth.request('https://auth.internal/steam/authorize');
|
const response = await auth.request('https://auth.internal/steam/authorize');
|
||||||
expect(response.status).toBe(302);
|
expect(response.status).toBe(404);
|
||||||
expect(response.headers.get('location')).toMatch(/steamcommunity\.com\/openid/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('full code flow and token verification', async () => {
|
test('there is no sign-in with a key', async () => {
|
||||||
const client = createClient({
|
const response = await auth.request('https://auth.internal/ssh/login', {
|
||||||
issuer: 'https://auth.internal',
|
method: 'POST',
|
||||||
clientID: 'api',
|
headers: { 'Content-Type': 'application/json' },
|
||||||
fetch: (input: any, init: any) => Promise.resolve(auth.request(input, init))
|
body: JSON.stringify({ fingerprint: 'SHA256:abc123', steamId: '76561198012345678' })
|
||||||
});
|
});
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
const { challenge, url } = await client.authorize(
|
test('asking for a code is where a sign-in starts', async () => {
|
||||||
'https://client.example.com/callback',
|
const response = await auth.request('https://auth.internal/code/authorize');
|
||||||
'code',
|
expect(response.status).toBe(200);
|
||||||
{ pkce: true, provider: 'steam' }
|
});
|
||||||
);
|
});
|
||||||
|
|
||||||
// Step 1: hit the authorize URL → redirects to Steam OpenID
|
/**
|
||||||
const authResponse = await auth.request(url);
|
* A cookie jar, because this flow needs two cookies at once.
|
||||||
expect(authResponse.status).toBe(302);
|
*
|
||||||
const cookie = authResponse.headers.get('set-cookie')!;
|
* `/authorize` sets the one holding the authorization, the code provider sets
|
||||||
expect(cookie).toBeDefined();
|
* the one holding its own state, and both have to be presented at the verify
|
||||||
|
* step. `Headers.get('set-cookie')` returns only the first of several, which
|
||||||
|
* silently drops one of them.
|
||||||
|
*/
|
||||||
|
function jar() {
|
||||||
|
const cookies = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
absorb(response: Response) {
|
||||||
|
for (const raw of response.headers.getSetCookie()) {
|
||||||
|
const [pair] = raw.split(';');
|
||||||
|
const index = pair!.indexOf('=');
|
||||||
|
cookies.set(pair!.slice(0, index), pair!.slice(index + 1));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
header() {
|
||||||
|
return [...cookies].map(([name, value]) => `${name}=${value}`).join('; ');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Step 2: simulate Steam redirecting back to our callback with valid OpenID params
|
/** Ask for a code, redeem it, and come back holding tokens. */
|
||||||
const callbackUrl =
|
async function signIn() {
|
||||||
'https://auth.internal/steam/callback?' +
|
const client = createClient({
|
||||||
'openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&' +
|
issuer: 'https://auth.internal',
|
||||||
'openid.mode=id_res&' +
|
clientID: 'api',
|
||||||
'openid.return_to=https%3A%2F%2Fauth.internal%2Fsteam%2Fcallback&' +
|
fetch: (input: any, init: any) => Promise.resolve(auth.request(input, init))
|
||||||
'openid.claimed_id=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956&' +
|
});
|
||||||
'openid.identity=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956';
|
|
||||||
|
|
||||||
const callbackResponse = await auth.request(callbackUrl, {
|
const { challenge, url } = await client.authorize('https://client.example.com/callback', 'code', {
|
||||||
headers: { cookie }
|
pkce: true,
|
||||||
});
|
provider: 'code'
|
||||||
expect(callbackResponse.status).toBe(302);
|
});
|
||||||
|
|
||||||
const location = new URL(callbackResponse.headers.get('location')!);
|
const cookies = jar();
|
||||||
const code = location.searchParams.get('code');
|
cookies.absorb(await auth.request(url));
|
||||||
expect(code).not.toBeNull();
|
expect(cookies.header()).not.toBe('');
|
||||||
|
|
||||||
const exchanged = await client.exchange(
|
const requested = await auth.request('https://auth.internal/code/authorize', {
|
||||||
code!,
|
method: 'POST',
|
||||||
'https://client.example.com/callback',
|
headers: { cookie: cookies.header(), 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
challenge.verifier
|
body: new URLSearchParams({ action: 'request', email: 'ada@example.com' })
|
||||||
);
|
});
|
||||||
if (exchanged.err) throw exchanged.err;
|
cookies.absorb(requested);
|
||||||
const tokens = exchanged.tokens!;
|
expect(lastCode).not.toBe('');
|
||||||
|
|
||||||
|
const verified = await auth.request('https://auth.internal/code/authorize', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { cookie: cookies.header(), 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ action: 'verify', code: lastCode })
|
||||||
|
});
|
||||||
|
expect(verified.status).toBe(302);
|
||||||
|
|
||||||
|
const location = new URL(verified.headers.get('location')!);
|
||||||
|
const code = location.searchParams.get('code');
|
||||||
|
expect(code).not.toBeNull();
|
||||||
|
|
||||||
|
const exchanged = await client.exchange(
|
||||||
|
code!,
|
||||||
|
'https://client.example.com/callback',
|
||||||
|
challenge.verifier
|
||||||
|
);
|
||||||
|
if (exchanged.err) throw exchanged.err;
|
||||||
|
return { client, tokens: exchanged.tokens! };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('signing in with an email address', () => {
|
||||||
|
test('a redeemed code becomes tokens that verify', async () => {
|
||||||
|
const { client, tokens } = await signIn();
|
||||||
|
|
||||||
expect(tokens.access).toBeString();
|
expect(tokens.access).toBeString();
|
||||||
expect(tokens.refresh).toBeString();
|
expect(tokens.refresh).toBeString();
|
||||||
@@ -130,88 +153,15 @@ describe('Steam auth flow', () => {
|
|||||||
type: 'user',
|
type: 'user',
|
||||||
properties: {
|
properties: {
|
||||||
userID: 'usr_test123',
|
userID: 'usr_test123',
|
||||||
linkedAccountID: 'lac_test456'
|
linkedAccountID: ''
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SSH login', () => {
|
|
||||||
test('valid login returns tokens', async () => {
|
|
||||||
const loginResponse = await auth.request('https://auth.internal/ssh/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: 'Bearer test-ssh-key'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
fingerprint: 'SHA256:abc123',
|
|
||||||
steamId: '76561198012345678'
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(loginResponse.status).toBe(200);
|
|
||||||
const body: any = await loginResponse.json();
|
|
||||||
expect(body.accessToken).toBeString();
|
|
||||||
expect(body.refreshToken).toBeString();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('invalid auth key returns 401', async () => {
|
|
||||||
const response = await auth.request('https://auth.internal/ssh/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: 'Bearer wrong-key'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
fingerprint: 'SHA256:abc123',
|
|
||||||
steamId: '76561198012345678'
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('User info', () => {
|
describe('User info', () => {
|
||||||
async function getTokens() {
|
|
||||||
const client = createClient({
|
|
||||||
issuer: 'https://auth.internal',
|
|
||||||
clientID: 'api',
|
|
||||||
fetch: (input: any, init: any) => Promise.resolve(auth.request(input, init))
|
|
||||||
});
|
|
||||||
|
|
||||||
const { challenge, url } = await client.authorize(
|
|
||||||
'https://client.example.com/callback',
|
|
||||||
'code',
|
|
||||||
{ pkce: true, provider: 'steam' }
|
|
||||||
);
|
|
||||||
|
|
||||||
const authResponse = await auth.request(url);
|
|
||||||
const cookie = authResponse.headers.get('set-cookie')!;
|
|
||||||
|
|
||||||
const callbackUrl =
|
|
||||||
'https://auth.internal/steam/callback?' +
|
|
||||||
'openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&' +
|
|
||||||
'openid.mode=id_res&' +
|
|
||||||
'openid.return_to=https%3A%2F%2Fauth.internal%2Fsteam%2Fcallback&' +
|
|
||||||
'openid.claimed_id=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956&' +
|
|
||||||
'openid.identity=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956';
|
|
||||||
|
|
||||||
const callbackResponse = await auth.request(callbackUrl, { headers: { cookie } });
|
|
||||||
const location = new URL(callbackResponse.headers.get('location')!);
|
|
||||||
const code = location.searchParams.get('code');
|
|
||||||
const exchanged = await client.exchange(
|
|
||||||
code!,
|
|
||||||
'https://client.example.com/callback',
|
|
||||||
challenge.verifier
|
|
||||||
);
|
|
||||||
if (exchanged.err) throw exchanged.err;
|
|
||||||
return { client, tokens: exchanged.tokens! };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('returns subject properties for valid access token', async () => {
|
test('returns subject properties for valid access token', async () => {
|
||||||
const { tokens } = await getTokens();
|
const { tokens } = await signIn();
|
||||||
|
|
||||||
const infoRes = await auth.request('https://auth.internal/userinfo', {
|
const infoRes = await auth.request('https://auth.internal/userinfo', {
|
||||||
headers: { Authorization: `Bearer ${tokens.access}` }
|
headers: { Authorization: `Bearer ${tokens.access}` }
|
||||||
@@ -221,7 +171,7 @@ describe('User info', () => {
|
|||||||
const userinfo = await infoRes.json();
|
const userinfo = await infoRes.json();
|
||||||
expect(userinfo).toMatchObject({
|
expect(userinfo).toMatchObject({
|
||||||
userID: 'usr_test123',
|
userID: 'usr_test123',
|
||||||
linkedAccountID: 'lac_test456'
|
linkedAccountID: ''
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user