create-ortha-app 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +6 -267
- package/dist/lib/features.d.ts +42 -20
- package/dist/lib/features.d.ts.map +1 -1
- package/dist/lib/features.js +56 -22
- package/dist/lib/run.d.ts +22 -0
- package/dist/lib/run.d.ts.map +1 -0
- package/dist/lib/run.js +318 -0
- package/dist/lib/template.d.ts.map +1 -1
- package/dist/lib/template.js +11 -1
- package/package.json +1 -1
- package/templates/default/README.md.tmpl +38 -12
- package/templates/default/apps/admin/src/plugins.spec.ts +5 -1
- package/templates/default/apps/admin/src/plugins.ts +21 -2
- package/templates/default/apps/admin/tsconfig.json +2 -1
- package/templates/default/apps/server/config/copilot-anthropic.ts +22 -0
- package/templates/default/apps/server/config/copilot-openai.ts +21 -0
- package/templates/default/apps/server/config/copilot.ts +61 -0
- package/templates/default/apps/server/config/docs.ts +14 -0
- package/templates/default/apps/server/config/i18n.ts +16 -0
- package/templates/default/apps/server/config/identity.ts +129 -0
- package/templates/default/apps/server/config/mcp.ts +25 -0
- package/templates/default/apps/server/config/media-storage.ts +84 -0
- package/templates/default/apps/server/config/media.ts +67 -0
- package/templates/default/apps/server/config/sso-github.ts +42 -0
- package/templates/default/apps/server/config/sso-oidc.ts +34 -0
- package/templates/default/apps/server/config/sso-saml.ts +46 -0
- package/templates/default/apps/server/ortha.config.ts +60 -449
- package/templates/default/apps/server/src/plugins.spec.ts +7 -0
- package/templates/default/apps/server/src/plugins.ts +84 -19
- package/templates/default/apps/server/tsconfig.spec.json +25 -0
- package/templates/default/env.tmpl +84 -2
- package/templates/default/tsconfig.json +3 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** Identity — sessions, tokens, the SSO handshake, and the first admin. */
|
|
2
|
+
import type { IdentityPluginConfig } from '@orthacms/identity-server';
|
|
3
|
+
// ortha:if sso-oidc
|
|
4
|
+
import type { OidcProviderConfig } from '@orthacms/identity-provider-oidc';
|
|
5
|
+
// ortha:end
|
|
6
|
+
// ortha:if sso-github
|
|
7
|
+
import type { GithubProviderConfig } from '@orthacms/identity-provider-github';
|
|
8
|
+
// ortha:end
|
|
9
|
+
// ortha:if sso-saml
|
|
10
|
+
import type { SamlProviderConfig } from '@orthacms/identity-provider-saml';
|
|
11
|
+
// ortha:end
|
|
12
|
+
import {
|
|
13
|
+
defined,
|
|
14
|
+
isProduction,
|
|
15
|
+
readEnv,
|
|
16
|
+
readList,
|
|
17
|
+
readPositiveInt
|
|
18
|
+
} from '@orthacms/utils-server';
|
|
19
|
+
|
|
20
|
+
// ortha:if sso-oidc
|
|
21
|
+
import { oidcProvider } from './sso-oidc';
|
|
22
|
+
// ortha:end
|
|
23
|
+
// ortha:if sso-github
|
|
24
|
+
import { githubProvider } from './sso-github';
|
|
25
|
+
// ortha:end
|
|
26
|
+
// ortha:if sso-saml
|
|
27
|
+
import { samlProvider } from './sso-saml';
|
|
28
|
+
// ortha:end
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Identity settings, plus the identity providers this app can reach.
|
|
32
|
+
*
|
|
33
|
+
* The provider settings live here rather than inside `IdentityPluginConfig`,
|
|
34
|
+
* for the same reason the copilot's backends do: the plugin names no protocol,
|
|
35
|
+
* and this file is the one place that reads the environment. The constructed
|
|
36
|
+
* adapters are registered in `src/plugins.ts`.
|
|
37
|
+
*/
|
|
38
|
+
export interface AppIdentityConfig extends IdentityPluginConfig {
|
|
39
|
+
/**
|
|
40
|
+
* Identity providers, keyed by the name they are registered under. That
|
|
41
|
+
* name appears in the sign-in URL and in every `sso_identities` row, so
|
|
42
|
+
* renaming one orphans the links that name it.
|
|
43
|
+
*
|
|
44
|
+
* Optional, and absent unless this app was generated with single sign-on.
|
|
45
|
+
*/
|
|
46
|
+
ssoProviders?: {
|
|
47
|
+
// ortha:if sso-oidc
|
|
48
|
+
/** A generic OpenID Connect provider. Present when both env vars are set. */
|
|
49
|
+
oidc?: OidcProviderConfig & { name: string };
|
|
50
|
+
// ortha:end
|
|
51
|
+
// ortha:if sso-github
|
|
52
|
+
/** GitHub or GitHub Enterprise Server. Present when both env vars are set. */
|
|
53
|
+
github?: GithubProviderConfig & { name: string };
|
|
54
|
+
// ortha:end
|
|
55
|
+
// ortha:if sso-saml
|
|
56
|
+
/** A SAML 2.0 identity provider. Present when all three env vars are set. */
|
|
57
|
+
saml?: SamlProviderConfig & { name: string };
|
|
58
|
+
// ortha:end
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Identity — sessions, tokens, the SSO handshake, and the first admin. */
|
|
63
|
+
export function identityConfig(): AppIdentityConfig {
|
|
64
|
+
return {
|
|
65
|
+
// Origins allowed to make state-changing calls (login-CSRF defence).
|
|
66
|
+
// In development that is the Vite dev server; in production the app is
|
|
67
|
+
// same-origin, so this list is what a separately-hosted admin would
|
|
68
|
+
// need adding to.
|
|
69
|
+
allowedOrigins: readList(
|
|
70
|
+
'ALLOWED_ORIGINS',
|
|
71
|
+
`http://localhost:${readPositiveInt('ADMIN_PORT', 4200)}`
|
|
72
|
+
),
|
|
73
|
+
session: {
|
|
74
|
+
ttlSeconds: readPositiveInt('SESSION_TTL_SECONDS', 60 * 60 * 24 * 7),
|
|
75
|
+
cookieSecure: isProduction(),
|
|
76
|
+
cookieSameSite: 'lax' as const
|
|
77
|
+
},
|
|
78
|
+
token: {
|
|
79
|
+
inviteTtlSeconds: readPositiveInt(
|
|
80
|
+
'INVITE_TTL_SECONDS',
|
|
81
|
+
60 * 60 * 24 * 7
|
|
82
|
+
),
|
|
83
|
+
resetTtlSeconds: readPositiveInt('RESET_TTL_SECONDS', 60 * 60)
|
|
84
|
+
},
|
|
85
|
+
rateLimit: {
|
|
86
|
+
ttlSeconds: readPositiveInt('LOGIN_RATE_LIMIT_TTL_SECONDS', 60),
|
|
87
|
+
limit: readPositiveInt('LOGIN_RATE_LIMIT', 10)
|
|
88
|
+
},
|
|
89
|
+
sso: ssoConfig(),
|
|
90
|
+
// ortha:if sso
|
|
91
|
+
ssoProviders: defined({
|
|
92
|
+
// ortha:if sso-oidc
|
|
93
|
+
oidc: oidcProvider(),
|
|
94
|
+
// ortha:end
|
|
95
|
+
// ortha:if sso-github
|
|
96
|
+
github: githubProvider(),
|
|
97
|
+
// ortha:end
|
|
98
|
+
// ortha:if sso-saml
|
|
99
|
+
saml: samlProvider()
|
|
100
|
+
// ortha:end
|
|
101
|
+
}),
|
|
102
|
+
// ortha:end
|
|
103
|
+
// With an email set, an admin is provisioned on boot — idempotent and
|
|
104
|
+
// non-destructive. This is how you get your first login.
|
|
105
|
+
// Read through `readEnv`, so all three are trimmed and a whitespace-only
|
|
106
|
+
// value is nothing rather than a value — a password of three spaces
|
|
107
|
+
// would otherwise be provisioned as the administrator's, silently.
|
|
108
|
+
rootAdmin: {
|
|
109
|
+
email: readEnv('ORTHA_ROOT_ADMIN_EMAIL') ?? '',
|
|
110
|
+
password: readEnv('ORTHA_ROOT_ADMIN_PASSWORD') ?? '',
|
|
111
|
+
name: readEnv('ORTHA_ROOT_ADMIN_NAME') ?? ''
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The shape of the single sign-on handshake. The providers themselves are
|
|
118
|
+
* constructed in `src/plugins.ts`; these settings say how the round trip runs.
|
|
119
|
+
*/
|
|
120
|
+
function ssoConfig(): NonNullable<IdentityPluginConfig['sso']> {
|
|
121
|
+
return defined({
|
|
122
|
+
// The origin browsers reach this API on. It builds the redirect_uri
|
|
123
|
+
// you register with each provider, and it is configured rather than
|
|
124
|
+
// read from the request's Host header, which a client controls. Leave
|
|
125
|
+
// it unset when the admin and the API share an origin — the usual case.
|
|
126
|
+
publicBaseUrl: readEnv('SSO_PUBLIC_BASE_URL'),
|
|
127
|
+
requestTtlSeconds: readPositiveInt('SSO_REQUEST_TTL_SECONDS', 600)
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// ortha:if mcp
|
|
2
|
+
import type { McpPluginConfig } from '@orthacms/mcp-server';
|
|
3
|
+
import { readFlag, readPositiveInt } from '@orthacms/utils-server';
|
|
4
|
+
|
|
5
|
+
/** The MCP front door — off unless an operator turns it on. */
|
|
6
|
+
export function mcpConfig(): McpPluginConfig {
|
|
7
|
+
return {
|
|
8
|
+
// Off by default: once on, any holder of a full-scope API token can
|
|
9
|
+
// drive content CRUD from an external agent.
|
|
10
|
+
enabled: readFlag('MCP_ENABLED', false),
|
|
11
|
+
// The identity MCP clients display in their connector lists.
|
|
12
|
+
name: '__APP_NAME__',
|
|
13
|
+
version: '1.0.0',
|
|
14
|
+
// A request/response transport owes its caller an answer, and the tool
|
|
15
|
+
// registry has no deadline of its own — so without this the only bound
|
|
16
|
+
// on a `tools/call` is the query underneath it, and a blocked pool
|
|
17
|
+
// turns one call into a socket held until the client gives up.
|
|
18
|
+
callTimeoutMs: readPositiveInt('MCP_CALL_TIMEOUT_MS', 30_000),
|
|
19
|
+
// Deliberately generous: the ceiling exists to stop a pathological
|
|
20
|
+
// result being serialised several times over, not to shape normal use.
|
|
21
|
+
// A result this large does not fit a model's context anyway.
|
|
22
|
+
maxResultBytes: readPositiveInt('MCP_MAX_RESULT_BYTES', 4_194_304)
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
// ortha:end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where uploads are written.
|
|
3
|
+
*
|
|
4
|
+
* One block per adapter, and the wizard picks exactly one — so a generated app
|
|
5
|
+
* has the body it chose and nothing else. Swapping backends later means
|
|
6
|
+
* swapping this file, the type on `AppMediaConfig.storage`, and the factory
|
|
7
|
+
* `src/plugins.ts` imports; nothing inside the media package changes.
|
|
8
|
+
*/
|
|
9
|
+
// ortha:if media-local
|
|
10
|
+
import type { LocalStorageConfig } from '@orthacms/media-provider-local';
|
|
11
|
+
import { readEnv } from '@orthacms/utils-server';
|
|
12
|
+
|
|
13
|
+
/** Local-filesystem blobs. */
|
|
14
|
+
export function mediaStorage(): LocalStorageConfig {
|
|
15
|
+
return {
|
|
16
|
+
// Point MEDIA_LOCAL_ROOT at a persistent volume in production: a
|
|
17
|
+
// container's own disk is wiped on every deploy.
|
|
18
|
+
rootDir: readEnv('MEDIA_LOCAL_ROOT') ?? './.storage/media'
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
// ortha:end
|
|
22
|
+
// ortha:if media-vercel-blob
|
|
23
|
+
import type { VercelBlobStorageConfig } from '@orthacms/media-provider-vercel-blob';
|
|
24
|
+
import { defined, readEnv } from '@orthacms/utils-server';
|
|
25
|
+
/** Vercel Blob. */
|
|
26
|
+
export function mediaStorage(): VercelBlobStorageConfig {
|
|
27
|
+
return defined({
|
|
28
|
+
// On Vercel the SDK reads BLOB_READ_WRITE_TOKEN itself, so this is only
|
|
29
|
+
// for running the app elsewhere.
|
|
30
|
+
token: readEnv('BLOB_READ_WRITE_TOKEN')
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
// ortha:end
|
|
34
|
+
// ortha:if media-gcs
|
|
35
|
+
import type { GcsStorageConfig } from '@orthacms/media-provider-gcs';
|
|
36
|
+
import { defined, readEnv, readFlag, requireEnv } from '@orthacms/utils-server';
|
|
37
|
+
/** Google Cloud Storage. */
|
|
38
|
+
export function mediaStorage(): GcsStorageConfig {
|
|
39
|
+
return defined({
|
|
40
|
+
bucket: requireEnv('MEDIA_GCS_BUCKET'),
|
|
41
|
+
// Everything else is optional: with no key file and no inline
|
|
42
|
+
// credentials the client uses Application Default Credentials, which is
|
|
43
|
+
// what a GKE or Cloud Run deployment wants.
|
|
44
|
+
projectId: readEnv('MEDIA_GCS_PROJECT_ID'),
|
|
45
|
+
keyFilename: readEnv('MEDIA_GCS_KEY_FILE'),
|
|
46
|
+
signWithIam: readFlag('MEDIA_GCS_SIGN_WITH_IAM', false)
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
// ortha:end
|
|
50
|
+
// ortha:if media-azure
|
|
51
|
+
import type { AzureStorageConfig } from '@orthacms/media-provider-azure';
|
|
52
|
+
import { requireEnv } from '@orthacms/utils-server';
|
|
53
|
+
/** Azure Blob Storage. */
|
|
54
|
+
export function mediaStorage(): AzureStorageConfig {
|
|
55
|
+
return {
|
|
56
|
+
container: requireEnv('MEDIA_AZURE_CONTAINER'),
|
|
57
|
+
connectionString: requireEnv('MEDIA_AZURE_CONNECTION_STRING')
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// ortha:end
|
|
61
|
+
// ortha:if media-s3
|
|
62
|
+
import type { S3StorageConfig } from '@orthacms/media-provider-s3';
|
|
63
|
+
import { defined, readEnv, readFlag, requireEnv } from '@orthacms/utils-server';
|
|
64
|
+
/** S3 or an S3-compatible endpoint — R2, MinIO, Spaces, B2. */
|
|
65
|
+
export function mediaStorage(): S3StorageConfig {
|
|
66
|
+
const accessKeyId = readEnv('MEDIA_S3_ACCESS_KEY_ID');
|
|
67
|
+
const secretAccessKey = readEnv('MEDIA_S3_SECRET_ACCESS_KEY');
|
|
68
|
+
return defined({
|
|
69
|
+
bucket: requireEnv('MEDIA_S3_BUCKET'),
|
|
70
|
+
// `auto` is what R2 expects; AWS needs its real region.
|
|
71
|
+
region: readEnv('MEDIA_S3_REGION') ?? 'auto',
|
|
72
|
+
// Omit for AWS S3 itself; set it for R2, MinIO, Spaces, B2…
|
|
73
|
+
endpoint: readEnv('MEDIA_S3_ENDPOINT'),
|
|
74
|
+
forcePathStyle: readFlag('MEDIA_S3_FORCE_PATH_STYLE', false),
|
|
75
|
+
// Absent means "use the SDK's own provider chain" — an instance role,
|
|
76
|
+
// IRSA, a shared config file. Passing blanks instead would shadow all
|
|
77
|
+
// of that with credentials that cannot sign.
|
|
78
|
+
credentials:
|
|
79
|
+
accessKeyId && secretAccessKey
|
|
80
|
+
? { accessKeyId, secretAccessKey }
|
|
81
|
+
: undefined
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// ortha:end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Media — the storage backend, plus how downloads and uploads are bounded. */
|
|
2
|
+
import type { MediaPluginConfig } from '@orthacms/media-server';
|
|
3
|
+
// ortha:if media-local
|
|
4
|
+
import type { LocalStorageConfig } from '@orthacms/media-provider-local';
|
|
5
|
+
// ortha:end
|
|
6
|
+
// ortha:if media-s3
|
|
7
|
+
import type { S3StorageConfig } from '@orthacms/media-provider-s3';
|
|
8
|
+
// ortha:end
|
|
9
|
+
// ortha:if media-azure
|
|
10
|
+
import type { AzureStorageConfig } from '@orthacms/media-provider-azure';
|
|
11
|
+
// ortha:end
|
|
12
|
+
// ortha:if media-gcs
|
|
13
|
+
import type { GcsStorageConfig } from '@orthacms/media-provider-gcs';
|
|
14
|
+
// ortha:end
|
|
15
|
+
// ortha:if media-vercel-blob
|
|
16
|
+
import type { VercelBlobStorageConfig } from '@orthacms/media-provider-vercel-blob';
|
|
17
|
+
// ortha:end
|
|
18
|
+
import { readEnv, readPositiveInt } from '@orthacms/utils-server';
|
|
19
|
+
|
|
20
|
+
import { mediaStorage } from './media-storage';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Media settings, plus whatever the storage backend `src/plugins.ts`
|
|
24
|
+
* constructs needs. The two move together: the type below is the one exported
|
|
25
|
+
* by the adapter that file imports.
|
|
26
|
+
*/
|
|
27
|
+
export interface AppMediaConfig extends MediaPluginConfig {
|
|
28
|
+
// ortha:if media-local
|
|
29
|
+
storage: LocalStorageConfig;
|
|
30
|
+
// ortha:end
|
|
31
|
+
// ortha:if media-s3
|
|
32
|
+
storage: S3StorageConfig;
|
|
33
|
+
// ortha:end
|
|
34
|
+
// ortha:if media-azure
|
|
35
|
+
storage: AzureStorageConfig;
|
|
36
|
+
// ortha:end
|
|
37
|
+
// ortha:if media-gcs
|
|
38
|
+
storage: GcsStorageConfig;
|
|
39
|
+
// ortha:end
|
|
40
|
+
// ortha:if media-vercel-blob
|
|
41
|
+
storage: VercelBlobStorageConfig;
|
|
42
|
+
// ortha:end
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Media — the storage backend, plus how downloads and uploads are bounded. */
|
|
46
|
+
export function mediaConfig(): AppMediaConfig {
|
|
47
|
+
return {
|
|
48
|
+
storage: mediaStorage(),
|
|
49
|
+
// Redirect an already-authorized download straight to the storage
|
|
50
|
+
// backend instead of streaming it through the app. Off unless asked
|
|
51
|
+
// for, and only possible on a backend that can sign a URL — the plugin
|
|
52
|
+
// refuses the combination at boot rather than proxying while the
|
|
53
|
+
// operator believes otherwise.
|
|
54
|
+
directServe:
|
|
55
|
+
readEnv('MEDIA_DIRECT_SERVE') === 'signed-url'
|
|
56
|
+
? 'signed-url'
|
|
57
|
+
: 'off',
|
|
58
|
+
directServeTtlSeconds: readPositiveInt(
|
|
59
|
+
'MEDIA_DIRECT_SERVE_TTL_SECONDS',
|
|
60
|
+
300
|
|
61
|
+
),
|
|
62
|
+
maxUploadBytes: readPositiveInt(
|
|
63
|
+
'MEDIA_MAX_UPLOAD_BYTES',
|
|
64
|
+
50 * 1024 * 1024
|
|
65
|
+
)
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// ortha:if sso-github
|
|
2
|
+
import type { GithubProviderConfig } from '@orthacms/identity-provider-github';
|
|
3
|
+
import { defined, readEnv, readList } from '@orthacms/utils-server';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The GitHub provider, or nothing.
|
|
7
|
+
*
|
|
8
|
+
* Present only when both values are set. The secret is not optional the way an
|
|
9
|
+
* OIDC one can be: GitHub's code exchange has no PKCE, so the secret is the
|
|
10
|
+
* only thing proving the code is being redeemed by this application — and the
|
|
11
|
+
* adapter refuses at construction rather than at the first sign-in, because
|
|
12
|
+
* every SSO failure looks the same to whoever clicked the button.
|
|
13
|
+
*/
|
|
14
|
+
export function githubProvider():
|
|
15
|
+
| (GithubProviderConfig & { name: string })
|
|
16
|
+
| undefined {
|
|
17
|
+
const clientId = readEnv('SSO_GITHUB_CLIENT_ID');
|
|
18
|
+
const clientSecret = readEnv('SSO_GITHUB_CLIENT_SECRET');
|
|
19
|
+
if (!clientId || !clientSecret) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
// `.env` ships this key blank, and `readList` answers a blank with `[]` —
|
|
23
|
+
// which is a value, not an absence, so passing it straight through would
|
|
24
|
+
// request *no* scopes and leave the profile read with nothing to read.
|
|
25
|
+
const scopes = readList('SSO_GITHUB_SCOPES', '');
|
|
26
|
+
return defined({
|
|
27
|
+
name: readEnv('SSO_GITHUB_NAME') ?? 'github',
|
|
28
|
+
clientId,
|
|
29
|
+
clientSecret,
|
|
30
|
+
label: readEnv('SSO_GITHUB_LABEL'),
|
|
31
|
+
// Defaults to `read:user user:email` — a profile and the verified
|
|
32
|
+
// addresses on it. GitHub's scopes are coarse, so anything wider hands
|
|
33
|
+
// the CMS access it has no use for.
|
|
34
|
+
scopes: scopes.length > 0 ? scopes : undefined,
|
|
35
|
+
// Only for GitHub Enterprise Server; github.com needs none.
|
|
36
|
+
enterpriseBaseUrl: readEnv('SSO_GITHUB_ENTERPRISE_BASE_URL'),
|
|
37
|
+
// Cosmetic, like Google's `hd`: it shapes the account chooser. Who
|
|
38
|
+
// actually gets in is this CMS's own decision, not GitHub's.
|
|
39
|
+
organization: readEnv('SSO_GITHUB_ORGANIZATION')
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
// ortha:end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// ortha:if sso-oidc
|
|
2
|
+
import type { OidcProviderConfig } from '@orthacms/identity-provider-oidc';
|
|
3
|
+
import { defined, readEnv, readFlag } from '@orthacms/utils-server';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The OIDC provider, or nothing.
|
|
7
|
+
*
|
|
8
|
+
* Present only when both values are set: an issuer with no client id becomes a
|
|
9
|
+
* sign-in button that can only fail, and every SSO failure looks the same, so
|
|
10
|
+
* whoever clicks it learns nothing.
|
|
11
|
+
*/
|
|
12
|
+
export function oidcProvider(): (OidcProviderConfig & { name: string }) | undefined {
|
|
13
|
+
const issuer = readEnv('SSO_OIDC_ISSUER');
|
|
14
|
+
const clientId = readEnv('SSO_OIDC_CLIENT_ID');
|
|
15
|
+
if (!issuer || !clientId) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
return defined({
|
|
19
|
+
name: readEnv('SSO_OIDC_NAME') ?? 'oidc',
|
|
20
|
+
issuer,
|
|
21
|
+
clientId,
|
|
22
|
+
clientSecret: readEnv('SSO_OIDC_CLIENT_SECRET'),
|
|
23
|
+
label: readEnv('SSO_OIDC_LABEL'),
|
|
24
|
+
// The only gate on a first sign-in claiming an existing account. A
|
|
25
|
+
// provider that omits the claim — Entra ID, notably — links nobody
|
|
26
|
+
// until an operator asserts that this directory owns the addresses it
|
|
27
|
+
// reports.
|
|
28
|
+
emailVerifiedWhenAbsent: readFlag(
|
|
29
|
+
'SSO_OIDC_EMAIL_VERIFIED_WHEN_ABSENT',
|
|
30
|
+
false
|
|
31
|
+
)
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
// ortha:end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// ortha:if sso-saml
|
|
2
|
+
import type { SamlProviderConfig } from '@orthacms/identity-provider-saml';
|
|
3
|
+
import { defined, readEnv, readFlag } from '@orthacms/utils-server';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The SAML provider, or nothing.
|
|
7
|
+
*
|
|
8
|
+
* All three of the entry point, the certificate and this app's entity id are
|
|
9
|
+
* required, and there is nothing to fall back to: SAML has no discovery
|
|
10
|
+
* document and no key endpoint, so the certificate an operator copies out of
|
|
11
|
+
* their IdP is the whole of the trust relationship.
|
|
12
|
+
*/
|
|
13
|
+
export function samlProvider():
|
|
14
|
+
| (SamlProviderConfig & { name: string })
|
|
15
|
+
| undefined {
|
|
16
|
+
const entryPoint = readEnv('SSO_SAML_ENTRY_POINT');
|
|
17
|
+
const idpCert = readEnv('SSO_SAML_IDP_CERT');
|
|
18
|
+
const issuer = readEnv('SSO_SAML_ISSUER');
|
|
19
|
+
if (!entryPoint || !idpCert || !issuer) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
return defined({
|
|
23
|
+
name: readEnv('SSO_SAML_NAME') ?? 'saml',
|
|
24
|
+
entryPoint,
|
|
25
|
+
// A PEM body on one line, `\n` escapes and all: an environment
|
|
26
|
+
// variable cannot hold real newlines, so they are put back here rather
|
|
27
|
+
// than left for the XML parser to fail on.
|
|
28
|
+
idpCert: idpCert.replace(/\\n/g, '\n'),
|
|
29
|
+
issuer,
|
|
30
|
+
label: readEnv('SSO_SAML_LABEL'),
|
|
31
|
+
// Worth setting whenever the IdP's NameID format is `emailAddress`: an
|
|
32
|
+
// address is not a stable identifier, and a profile whose subject is
|
|
33
|
+
// one is refused.
|
|
34
|
+
subjectAttribute: readEnv('SSO_SAML_SUBJECT_ATTRIBUTE'),
|
|
35
|
+
emailAttribute: readEnv('SSO_SAML_EMAIL_ATTRIBUTE'),
|
|
36
|
+
nameAttribute: readEnv('SSO_SAML_NAME_ATTRIBUTE'),
|
|
37
|
+
groupsAttribute: readEnv('SSO_SAML_GROUPS_ATTRIBUTE'),
|
|
38
|
+
// Defaults to false, and stays an assertion rather than a reading:
|
|
39
|
+
// **SAML carries no verification claim at all**, so there is nothing
|
|
40
|
+
// an adapter could inspect and be honest about. Setting it says this
|
|
41
|
+
// directory is authoritative for the addresses it reports — which is
|
|
42
|
+
// the only gate on a first sign-in claiming an existing account.
|
|
43
|
+
emailVerified: readFlag('SSO_SAML_EMAIL_VERIFIED', false)
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// ortha:end
|