mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
feat: Sync to OSS repo
This commit is contained in:
20
apps/auth/package.json
Normal file
20
apps/auth/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "auth",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestri/auth": "workspace:",
|
||||
"@nestri/core": "workspace:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
112
apps/auth/src/index.ts
Normal file
112
apps/auth/src/index.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { Hyperdrive, KVNamespace } from '@cloudflare/workers-types';
|
||||
import { issuer } from '@nestri/auth/index';
|
||||
import { SshProvider } from '@nestri/auth/provider/ssh';
|
||||
import { SteamProvider } from '@nestri/auth/provider/steam';
|
||||
import { CloudflareStorage } from '@nestri/auth/storage/cloudflare';
|
||||
import { subjects } from '@nestri/core/auth/subjects';
|
||||
import { Database } from '@nestri/core/db/index';
|
||||
import { Env } from '@nestri/core/env';
|
||||
import { Identifier } from '@nestri/core/id';
|
||||
import { Steam } from '@nestri/core/steam/index';
|
||||
import { User } from '@nestri/core/user/index';
|
||||
import { LinkedAccount } from '@nestri/core/user/linked-account';
|
||||
|
||||
type Env = {
|
||||
AuthStorage: KVNamespace;
|
||||
HYPERDRIVE: Hyperdrive;
|
||||
STEAM_API_KEY: string;
|
||||
SSH_AUTH_KEY: string;
|
||||
};
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||
Env.init(env as unknown as Record<string, unknown>);
|
||||
const inner = issuer({
|
||||
subjects,
|
||||
storage: CloudflareStorage({
|
||||
namespace: env.AuthStorage
|
||||
}),
|
||||
providers: {
|
||||
steam: SteamProvider(),
|
||||
ssh: SshProvider({ sshAuthKey: env.SSH_AUTH_KEY })
|
||||
},
|
||||
async success(context, response) {
|
||||
if (response.provider === 'steam') {
|
||||
const { steamid } = response;
|
||||
const profileUrl = new URL(
|
||||
'https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/'
|
||||
);
|
||||
profileUrl.searchParams.set('key', env.STEAM_API_KEY);
|
||||
profileUrl.searchParams.set('steamids', steamid);
|
||||
|
||||
const profileRes = await fetch(profileUrl.toString());
|
||||
const profileData = (await profileRes.json()) as {
|
||||
response?: { players?: Array<Record<string, unknown>> };
|
||||
};
|
||||
|
||||
const player = profileData?.response?.players?.[0] as any;
|
||||
const personaname: string = player?.personaname ?? 'Player';
|
||||
const avatarfull: string = player?.avatarfull;
|
||||
|
||||
const { userID, linkedAccountID } = await Database.transaction(async () => {
|
||||
const existing = await LinkedAccount.findByProvider({
|
||||
provider: 'steam',
|
||||
providerAccountId: steamid
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const user = await User.fromID(existing.userId);
|
||||
if (!user) throw new Error('User not found for linked account');
|
||||
return { userID: user.id, linkedAccountID: existing.id };
|
||||
}
|
||||
|
||||
const newUserID = Identifier.ascending('user');
|
||||
await User.create({
|
||||
id: newUserID,
|
||||
name: personaname,
|
||||
email: undefined,
|
||||
emailVerified: false,
|
||||
image: avatarfull ?? null
|
||||
});
|
||||
|
||||
const newLinkedAccountID = Identifier.ascending('linkedAccount');
|
||||
await LinkedAccount.create({
|
||||
id: newLinkedAccountID,
|
||||
userId: newUserID,
|
||||
provider: 'steam',
|
||||
providerAccountId: steamid,
|
||||
profile: player ?? {}
|
||||
});
|
||||
|
||||
return { userID: newUserID, linkedAccountID: newLinkedAccountID };
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
return context.subject('user', {
|
||||
userID,
|
||||
linkedAccountID,
|
||||
fingerprint
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error('Unknown provider');
|
||||
}
|
||||
});
|
||||
|
||||
return inner.fetch(request, env, ctx);
|
||||
}
|
||||
};
|
||||
227
apps/auth/test/worker.test.ts
Normal file
227
apps/auth/test/worker.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import { createClient } from '@nestri/auth/client';
|
||||
import { issuer } from '@nestri/auth/index';
|
||||
import { SshProvider } from '@nestri/auth/provider/ssh';
|
||||
import { SteamProvider } from '@nestri/auth/provider/steam';
|
||||
import { MemoryStorage } from '@nestri/auth/storage/memory';
|
||||
import { subjects } from '@nestri/core/auth/subjects';
|
||||
|
||||
const storage = MemoryStorage();
|
||||
|
||||
const auth = issuer({
|
||||
subjects,
|
||||
storage,
|
||||
allow: async () => true,
|
||||
providers: {
|
||||
steam: SteamProvider(),
|
||||
ssh: SshProvider({ sshAuthKey: 'test-ssh-key' })
|
||||
},
|
||||
async success(context, response) {
|
||||
if (response.provider === 'steam') {
|
||||
return context.subject('user', {
|
||||
userID: 'usr_test123',
|
||||
linkedAccountID: 'lac_test456'
|
||||
});
|
||||
}
|
||||
if (response.provider === 'ssh') {
|
||||
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) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
|
||||
if (url.includes('steamcommunity.com/openid/login')) {
|
||||
return new Response('ns:http://specs.openid.net/auth/2.0\nis_valid:true\n', { status: 200 });
|
||||
}
|
||||
|
||||
if (url.includes('api.steampowered.com')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
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');
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get('location')).toMatch(/steamcommunity\.com\/openid/);
|
||||
});
|
||||
|
||||
test('full code flow and token verification', async () => {
|
||||
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' }
|
||||
);
|
||||
|
||||
// Step 1: hit the authorize URL → redirects to Steam OpenID
|
||||
const authResponse = await auth.request(url);
|
||||
expect(authResponse.status).toBe(302);
|
||||
const cookie = authResponse.headers.get('set-cookie')!;
|
||||
expect(cookie).toBeDefined();
|
||||
|
||||
// Step 2: simulate Steam redirecting back to our callback with valid OpenID params
|
||||
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 }
|
||||
});
|
||||
expect(callbackResponse.status).toBe(302);
|
||||
|
||||
const location = new URL(callbackResponse.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;
|
||||
const tokens = exchanged.tokens!;
|
||||
|
||||
expect(tokens.access).toBeString();
|
||||
expect(tokens.refresh).toBeString();
|
||||
|
||||
const verified = await client.verify(subjects, tokens.access);
|
||||
if (verified.err) throw verified.err;
|
||||
expect(verified.subject).toEqual({
|
||||
type: 'user',
|
||||
properties: {
|
||||
userID: 'usr_test123',
|
||||
linkedAccountID: 'lac_test456'
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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 () => {
|
||||
const { tokens } = await getTokens();
|
||||
|
||||
const infoRes = await auth.request('https://auth.internal/userinfo', {
|
||||
headers: { Authorization: `Bearer ${tokens.access}` }
|
||||
});
|
||||
|
||||
expect(infoRes.status).toBe(200);
|
||||
const userinfo = await infoRes.json();
|
||||
expect(userinfo).toMatchObject({
|
||||
userID: 'usr_test123',
|
||||
linkedAccountID: 'lac_test456'
|
||||
});
|
||||
});
|
||||
});
|
||||
11
apps/auth/tsconfig.json
Normal file
11
apps/auth/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "react",
|
||||
"types": ["@cloudflare/workers-types", "node", "bun"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user