@wtfalch/auth 0.1.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/auth.d.ts +136 -0
- package/dist/auth.js +403 -0
- package/dist/broker.d.ts +63 -0
- package/dist/broker.js +102 -0
- package/dist/config.d.ts +51 -0
- package/dist/config.js +71 -0
- package/dist/cookies.d.ts +45 -0
- package/dist/cookies.js +131 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/next.d.ts +68 -0
- package/dist/next.js +111 -0
- package/dist/oidc.d.ts +35 -0
- package/dist/oidc.js +128 -0
- package/dist/redirect.d.ts +2 -0
- package/dist/redirect.js +16 -0
- package/package.json +54 -0
package/dist/oidc.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import * as jose from 'jose';
|
|
2
|
+
import * as client from 'openid-client';
|
|
3
|
+
export const ORG_CLAIM = 'urn:zitadel:iam:user:resourceowner:id';
|
|
4
|
+
export class Oidc {
|
|
5
|
+
options;
|
|
6
|
+
configuration = null;
|
|
7
|
+
jwks = null;
|
|
8
|
+
refreshing = new Map();
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
}
|
|
12
|
+
config() {
|
|
13
|
+
if (!this.configuration) {
|
|
14
|
+
this.configuration = client
|
|
15
|
+
.discovery(new URL(this.options.issuer), this.options.clientId, { id_token_signed_response_alg: 'RS256' }, client.None(), { [client.customFetch]: (url, init) => this.options.fetch(url, init) })
|
|
16
|
+
.then((config) => {
|
|
17
|
+
if (config.serverMetadata().issuer !== this.options.issuer) {
|
|
18
|
+
throw new Error(`@wtfalch/auth: discovered issuer ${config.serverMetadata().issuer} is not ${this.options.issuer}`);
|
|
19
|
+
}
|
|
20
|
+
return config;
|
|
21
|
+
})
|
|
22
|
+
.catch((error) => {
|
|
23
|
+
this.configuration = null;
|
|
24
|
+
throw error;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return this.configuration;
|
|
28
|
+
}
|
|
29
|
+
async keys() {
|
|
30
|
+
if (!this.jwks) {
|
|
31
|
+
const { jwks_uri } = (await this.config()).serverMetadata();
|
|
32
|
+
if (!jwks_uri)
|
|
33
|
+
throw new Error('@wtfalch/auth: the issuer publishes no jwks_uri');
|
|
34
|
+
this.jwks = jose.createRemoteJWKSet(new URL(jwks_uri), {
|
|
35
|
+
// jose's default 30s would reject tokens signed with a freshly rotated key for that long
|
|
36
|
+
cooldownDuration: 5_000,
|
|
37
|
+
[jose.customFetch]: (url, init) => this.options.fetch(url, init),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return this.jwks;
|
|
41
|
+
}
|
|
42
|
+
get redirectUri() {
|
|
43
|
+
return new URL(`${this.options.basePath}/callback`, this.options.appUrl);
|
|
44
|
+
}
|
|
45
|
+
async authorizationUrl(state, nonce, codeVerifier, { create = false } = {}) {
|
|
46
|
+
return client.buildAuthorizationUrl(await this.config(), {
|
|
47
|
+
...(create ? { prompt: 'create' } : {}),
|
|
48
|
+
redirect_uri: this.redirectUri.href,
|
|
49
|
+
scope: [
|
|
50
|
+
'openid',
|
|
51
|
+
'email',
|
|
52
|
+
'profile',
|
|
53
|
+
'offline_access',
|
|
54
|
+
`urn:zitadel:iam:org:id:${this.options.organizationId}`,
|
|
55
|
+
'urn:zitadel:iam:user:resourceowner',
|
|
56
|
+
].join(' '),
|
|
57
|
+
state,
|
|
58
|
+
nonce,
|
|
59
|
+
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
|
|
60
|
+
code_challenge_method: 'S256',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
async exchange(callbackParams, transaction) {
|
|
64
|
+
// The library derives redirect_uri from this URL, and behind a proxy the request's own URL is not the registered one.
|
|
65
|
+
const current = new URL(this.redirectUri);
|
|
66
|
+
current.search = callbackParams.toString();
|
|
67
|
+
const response = await client.authorizationCodeGrant(await this.config(), current, {
|
|
68
|
+
expectedState: transaction.state,
|
|
69
|
+
expectedNonce: transaction.nonce,
|
|
70
|
+
pkceCodeVerifier: transaction.codeVerifier,
|
|
71
|
+
idTokenExpected: true,
|
|
72
|
+
});
|
|
73
|
+
return this.tokensFrom(response);
|
|
74
|
+
}
|
|
75
|
+
// Parallel requests holding the same refresh token share one grant; the issuer rotates it, so a second grant would fail.
|
|
76
|
+
refresh(refreshToken) {
|
|
77
|
+
let pending = this.refreshing.get(refreshToken);
|
|
78
|
+
if (!pending) {
|
|
79
|
+
pending = this.config()
|
|
80
|
+
.then((config) => client.refreshTokenGrant(config, refreshToken))
|
|
81
|
+
.then((response) => this.tokensFrom(response, refreshToken))
|
|
82
|
+
.finally(() => this.refreshing.delete(refreshToken));
|
|
83
|
+
this.refreshing.set(refreshToken, pending);
|
|
84
|
+
}
|
|
85
|
+
return pending;
|
|
86
|
+
}
|
|
87
|
+
tokensFrom(response, previousRefreshToken) {
|
|
88
|
+
const claims = response.claims();
|
|
89
|
+
if (!response.id_token || !claims) {
|
|
90
|
+
throw new AuthError('exchange', 'the issuer returned no id token');
|
|
91
|
+
}
|
|
92
|
+
this.assertOrganization(claims);
|
|
93
|
+
return {
|
|
94
|
+
idToken: response.id_token,
|
|
95
|
+
refreshToken: response.refresh_token ?? previousRefreshToken,
|
|
96
|
+
claims,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
async verify(idToken) {
|
|
100
|
+
const { payload } = await jose.jwtVerify(idToken, await this.keys(), {
|
|
101
|
+
issuer: this.options.issuer,
|
|
102
|
+
audience: this.options.clientId,
|
|
103
|
+
algorithms: ['RS256'],
|
|
104
|
+
clockTolerance: 60,
|
|
105
|
+
});
|
|
106
|
+
// ZITADEL puts the project in aud as well, so azp has to name us. Same rule openid-client applies on exchange.
|
|
107
|
+
if (Array.isArray(payload.aud) &&
|
|
108
|
+
payload.aud.length !== 1 &&
|
|
109
|
+
payload.azp !== this.options.clientId) {
|
|
110
|
+
throw new AuthError('exchange', 'the token was authorised for another party');
|
|
111
|
+
}
|
|
112
|
+
this.assertOrganization(payload);
|
|
113
|
+
return payload;
|
|
114
|
+
}
|
|
115
|
+
assertOrganization(claims) {
|
|
116
|
+
if (claims[ORG_CLAIM] !== this.options.organizationId) {
|
|
117
|
+
throw new AuthError('organization', `the token's organisation ${String(claims[ORG_CLAIM])} is not this app's`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export class AuthError extends Error {
|
|
122
|
+
reason;
|
|
123
|
+
constructor(reason, message) {
|
|
124
|
+
super(message);
|
|
125
|
+
this.reason = reason;
|
|
126
|
+
this.name = 'AuthError';
|
|
127
|
+
}
|
|
128
|
+
}
|
package/dist/redirect.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** A path on this app, or the fallback. Refuses absolute, scheme-relative and backslash forms. */
|
|
2
|
+
export function safeNextPath(next, fallback) {
|
|
3
|
+
if (!next || !next.startsWith('/') || next.startsWith('//') || next.startsWith('/\\')) {
|
|
4
|
+
return fallback;
|
|
5
|
+
}
|
|
6
|
+
let url;
|
|
7
|
+
try {
|
|
8
|
+
url = new URL(next, 'https://app.invalid');
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return fallback;
|
|
12
|
+
}
|
|
13
|
+
if (url.origin !== 'https://app.invalid')
|
|
14
|
+
return fallback;
|
|
15
|
+
return `${url.pathname}${url.search}`;
|
|
16
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wtfalch/auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sign in against auth.wtfalch.dev from a Next.js app.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/wtfalch/auth",
|
|
8
|
+
"directory": "packages/auth"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"files": ["dist"],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./next": {
|
|
18
|
+
"types": "./dist/next.d.ts",
|
|
19
|
+
"default": "./dist/next.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22.0.0"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
28
|
+
"prepack": "pnpm build",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"test": "vitest run"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"jose": "^6.2.10",
|
|
34
|
+
"openid-client": "6.8.7"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"next": ">=15.0.0"
|
|
38
|
+
},
|
|
39
|
+
"peerDependenciesMeta": {
|
|
40
|
+
"next": {
|
|
41
|
+
"optional": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^22",
|
|
46
|
+
"@types/react": "^19",
|
|
47
|
+
"@wtfalch/auth-broker": "workspace:*",
|
|
48
|
+
"next": "^16",
|
|
49
|
+
"playwright-core": "^1.62.1",
|
|
50
|
+
"react": "^19",
|
|
51
|
+
"typescript": "^5.9.0",
|
|
52
|
+
"vitest": "^4.1.6"
|
|
53
|
+
}
|
|
54
|
+
}
|