|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #29972 — `objectstack serve` hands the deployment's registered app schemes |
| 4 | +// to AuthPlugin, from `OS_TRUSTED_APP_SCHEMES`. |
| 5 | +// |
| 6 | +// The mobile sign-in hand-off (`GET /api/v1/auth/one-time-token/handoff`) |
| 7 | +// redirects a one-time token only to a scheme `AuthConfig.trustedAppSchemes` |
| 8 | +// registers, and plugin-auth mounts the `one-time-token` plugin only when that |
| 9 | +// list is non-empty. `serve` used to construct AuthPlugin with no |
| 10 | +// `trustedAppSchemes` and nothing a deployment could set reached the key, so |
| 11 | +// the hand-off refused every app (`400 … is not a registered app scheme`). |
| 12 | +// |
| 13 | +// Three halves. The reader half drives `resolveTrustedAppSchemes` over the |
| 14 | +// shapes an operator types: unset stays unset (no default scheme), entries are |
| 15 | +// trimmed, empties dropped and repeats folded the way `OS_TRUSTED_ORIGINS` is |
| 16 | +// read, and nothing is validated or dropped. The refusal half hands what the |
| 17 | +// reader answers for a bad entry to a real `AuthManager` (what AuthPlugin |
| 18 | +// builds at init) and shows the boot still fails loudly, naming the entry. The |
| 19 | +// construction half scans `serve.ts` with comments masked, as the sibling |
| 20 | +// `serve-auth-*.contract.test.ts` files do: AuthPlugin is imported through a |
| 21 | +// variable specifier, so the type checker cannot see the literal. |
| 22 | + |
| 23 | +import { describe, it, expect } from 'vitest'; |
| 24 | +import { readFileSync } from 'node:fs'; |
| 25 | +import path from 'node:path'; |
| 26 | +import { fileURLToPath } from 'node:url'; |
| 27 | +import { AuthManager } from '@objectstack/plugin-auth'; |
| 28 | +import { resolveTrustedAppSchemes, TRUSTED_APP_SCHEMES_ENV_NAME } from './serve.js'; |
| 29 | +import { maskComments } from '../../../../scripts/js-comment-mask.mjs'; |
| 30 | + |
| 31 | +const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 32 | +const SERVE_SOURCE = maskComments(readFileSync(path.resolve(HERE, 'serve.ts'), 'utf8')); |
| 33 | +const ENV_DOC = readFileSync( |
| 34 | + path.resolve(HERE, '../../../../content/docs/deployment/environment-variables.mdx'), |
| 35 | + 'utf8', |
| 36 | +); |
| 37 | + |
| 38 | +/** The argument text of every `new AuthPlugin(` call. */ |
| 39 | +function authPluginConstructions(source: string): string[] { |
| 40 | + const out: string[] = []; |
| 41 | + const needle = 'new AuthPlugin('; |
| 42 | + for (let i = source.indexOf(needle); i !== -1; i = source.indexOf(needle, i + 1)) { |
| 43 | + const open = i + needle.length - 1; |
| 44 | + let depth = 0; |
| 45 | + for (let j = open; j < source.length; j++) { |
| 46 | + if (source[j] === '(') depth++; |
| 47 | + else if (source[j] === ')' && --depth === 0) { |
| 48 | + out.push(source.slice(open + 1, j)); |
| 49 | + break; |
| 50 | + } |
| 51 | + } |
| 52 | + } |
| 53 | + return out; |
| 54 | +} |
| 55 | + |
| 56 | +const normalise = (text: string) => text.replace(/\s+/g, ''); |
| 57 | +const env = (value?: string): NodeJS.ProcessEnv => |
| 58 | + value === undefined ? {} : { [TRUSTED_APP_SCHEMES_ENV_NAME]: value }; |
| 59 | + |
| 60 | +const managerWith = (trustedAppSchemes: string[] | undefined) => |
| 61 | + new AuthManager({ |
| 62 | + secret: 'a-test-secret-that-is-at-least-32-chars-long', |
| 63 | + baseUrl: 'http://localhost:3000', |
| 64 | + trustedAppSchemes, |
| 65 | + } as any); |
| 66 | + |
| 67 | +describe('resolveTrustedAppSchemes reads OS_TRUSTED_APP_SCHEMES', () => { |
| 68 | + it('reads the variable named OS_TRUSTED_APP_SCHEMES', () => { |
| 69 | + expect(TRUSTED_APP_SCHEMES_ENV_NAME).toBe('OS_TRUSTED_APP_SCHEMES'); |
| 70 | + }); |
| 71 | + |
| 72 | + it('unset stays unset: no default scheme', () => { |
| 73 | + expect(resolveTrustedAppSchemes(env())).toBeUndefined(); |
| 74 | + }); |
| 75 | + |
| 76 | + it('set but empty, or only separators and spaces, registers nothing', () => { |
| 77 | + for (const value of ['', ' ', ',', ' , ,, ']) { |
| 78 | + expect(resolveTrustedAppSchemes(env(value)), JSON.stringify(value)).toBeUndefined(); |
| 79 | + } |
| 80 | + }); |
| 81 | + |
| 82 | + it('one scheme', () => { |
| 83 | + expect(resolveTrustedAppSchemes(env('ai.objectos.mobile'))).toEqual(['ai.objectos.mobile']); |
| 84 | + }); |
| 85 | + |
| 86 | + it('trims entries, drops empties and folds repeats, in the order typed', () => { |
| 87 | + expect( |
| 88 | + resolveTrustedAppSchemes(env(' ai.objectos.mobile , ,com.example.app,ai.objectos.mobile, ')), |
| 89 | + ).toEqual(['ai.objectos.mobile', 'com.example.app']); |
| 90 | + }); |
| 91 | + |
| 92 | + it('passes a bad entry through as typed: never validated, lower-cased or dropped here', () => { |
| 93 | + expect(resolveTrustedAppSchemes(env('ai.objectos.mobile, Https,AI.Objectos.Mobile'))).toEqual([ |
| 94 | + 'ai.objectos.mobile', |
| 95 | + 'Https', |
| 96 | + 'AI.Objectos.Mobile', |
| 97 | + ]); |
| 98 | + }); |
| 99 | +}); |
| 100 | + |
| 101 | +describe('what the reader answers is what AuthManager registers or refuses', () => { |
| 102 | + it('a set variable registers its schemes', () => { |
| 103 | + const manager = managerWith(resolveTrustedAppSchemes(env('ai.objectos.mobile, com.example.app'))); |
| 104 | + expect(manager.getRegisteredAppSchemes()).toEqual(['ai.objectos.mobile', 'com.example.app']); |
| 105 | + }); |
| 106 | + |
| 107 | + it('an unset variable registers none', () => { |
| 108 | + expect(managerWith(resolveTrustedAppSchemes(env())).getRegisteredAppSchemes()).toEqual([]); |
| 109 | + }); |
| 110 | + |
| 111 | + it('a bad entry fails construction loudly, naming the entry', () => { |
| 112 | + expect(() => managerWith(resolveTrustedAppSchemes(env('ai.objectos.mobile,myapp')))).toThrow( |
| 113 | + /invalid trustedAppSchemes: "myapp"/, |
| 114 | + ); |
| 115 | + }); |
| 116 | +}); |
| 117 | + |
| 118 | +describe('serve constructs AuthPlugin with the schemes OS_TRUSTED_APP_SCHEMES names', () => { |
| 119 | + it('passes trustedAppSchemes from resolveTrustedAppSchemes(process.env)', () => { |
| 120 | + const constructions = authPluginConstructions(SERVE_SOURCE); |
| 121 | + // Absence must be loud: a scan that finds no construction proves nothing. |
| 122 | + expect(constructions.length, 'serve.ts must construct AuthPlugin exactly once').toBe(1); |
| 123 | + expect(normalise(constructions[0])).toContain('trustedAppSchemes:resolveTrustedAppSchemes(process.env),'); |
| 124 | + }); |
| 125 | + |
| 126 | + it('reads the variable nowhere else in serve.ts', () => { |
| 127 | + // One reader: a second `process.env.OS_TRUSTED_APP_SCHEMES` could disagree |
| 128 | + // with the one AuthPlugin is built from. |
| 129 | + expect(SERVE_SOURCE).not.toMatch(/process\.env\.OS_TRUSTED_APP_SCHEMES|\['OS_TRUSTED_APP_SCHEMES'\]/); |
| 130 | + }); |
| 131 | + |
| 132 | + it('is listed in the environment-variables reference next to OS_TRUSTED_ORIGINS', () => { |
| 133 | + const rows = ENV_DOC.split('\n'); |
| 134 | + const origins = rows.findIndex((row) => row.startsWith('| `OS_TRUSTED_ORIGINS` |')); |
| 135 | + expect(origins, 'the OS_TRUSTED_ORIGINS row').toBeGreaterThan(-1); |
| 136 | + expect(rows[origins + 1]).toMatch(/^\| `OS_TRUSTED_APP_SCHEMES` \| csv \|/); |
| 137 | + }); |
| 138 | +}); |
0 commit comments