@aplons/auth 0.1.0 → 0.2.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/README.md +88 -28
- package/dist/branding.d.ts +82 -0
- package/dist/branding.js +80 -0
- package/dist/client.d.ts +78 -55
- package/dist/client.js +173 -124
- package/dist/discovery.d.ts +11 -9
- package/dist/discovery.js +26 -26
- package/dist/errors.d.ts +12 -12
- package/dist/errors.js +49 -45
- package/dist/index.d.ts +12 -12
- package/dist/index.js +10 -10
- package/dist/next.d.ts +39 -37
- package/dist/next.js +259 -159
- package/dist/pkce.d.ts +27 -29
- package/dist/pkce.js +41 -43
- package/dist/types.d.ts +31 -32
- package/dist/verify.d.ts +14 -14
- package/dist/verify.js +47 -47
- package/package.json +8 -6
package/dist/pkce.js
CHANGED
|
@@ -1,75 +1,73 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PKCE —
|
|
3
|
-
* angefordert hat.
|
|
2
|
+
* PKCE — proof that whoever redeems the code is the one who asked for it.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* The code comes back through a redirect URL, which puts it in the browser
|
|
5
|
+
* history, in the log of every proxy in between, and in the referer of the
|
|
6
|
+
* next page. Without PKCE, anyone who reads it there can redeem it. With
|
|
7
|
+
* PKCE they also need the verifier, and that never left the browser.
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Everything here runs on built-in Web Crypto — no package, no dependency.
|
|
10
|
+
* Available in Node 18+, in every browser, and at the edge.
|
|
12
11
|
*/
|
|
13
|
-
/**
|
|
14
|
-
function
|
|
15
|
-
const
|
|
16
|
-
crypto.getRandomValues(
|
|
17
|
-
return
|
|
12
|
+
/** The randomness everything else follows from. */
|
|
13
|
+
function randomBytes(count) {
|
|
14
|
+
const buffer = new Uint8Array(count);
|
|
15
|
+
crypto.getRandomValues(buffer);
|
|
16
|
+
return buffer;
|
|
18
17
|
}
|
|
19
18
|
/**
|
|
20
|
-
* Base64
|
|
19
|
+
* Base64 without the three characters that mean something else in a URL.
|
|
21
20
|
*
|
|
22
|
-
* `+`
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* außer nach seiner Ursache.
|
|
21
|
+
* `+` becomes a space in form encoding, `/` separates path segments, and `=`
|
|
22
|
+
* separates a parameter from its value. A verifier containing them arrives
|
|
23
|
+
* altered at the other end and no longer matches its challenge — a failure
|
|
24
|
+
* that surfaces as `invalid_grant` and looks like anything but its cause.
|
|
27
25
|
*/
|
|
28
|
-
export function base64url(
|
|
29
|
-
const bytes =
|
|
30
|
-
let
|
|
26
|
+
export function base64url(data) {
|
|
27
|
+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
28
|
+
let raw = "";
|
|
31
29
|
for (const byte of bytes)
|
|
32
|
-
|
|
33
|
-
return btoa(
|
|
30
|
+
raw += String.fromCharCode(byte);
|
|
31
|
+
return btoa(raw).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
34
32
|
}
|
|
35
33
|
/**
|
|
36
|
-
*
|
|
34
|
+
* A verifier: 32 bytes of randomness, base64url — 43 characters.
|
|
37
35
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
36
|
+
* That is the lower bound from RFC 7636 and it is also enough: 256 bits of
|
|
37
|
+
* randomness cannot be guessed. Going up to the 128-character limit would
|
|
38
|
+
* add nothing.
|
|
41
39
|
*/
|
|
42
40
|
export function createVerifier() {
|
|
43
|
-
return base64url(
|
|
41
|
+
return base64url(randomBytes(32));
|
|
44
42
|
}
|
|
45
|
-
/**
|
|
43
|
+
/** What goes into the authorization URL: the hash, never the verifier. */
|
|
46
44
|
export async function createChallenge(verifier) {
|
|
47
45
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
48
46
|
return base64url(digest);
|
|
49
47
|
}
|
|
50
48
|
/**
|
|
51
|
-
*
|
|
49
|
+
* The value that guards against cross-site request forgery.
|
|
52
50
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
51
|
+
* Without it someone could start a login with *their* account and hand the
|
|
52
|
+
* victim the finished redirect URL; the victim would end up signed into a
|
|
53
|
+
* stranger's account and file their data there.
|
|
56
54
|
*/
|
|
57
55
|
export function createState() {
|
|
58
|
-
return base64url(
|
|
56
|
+
return base64url(randomBytes(16));
|
|
59
57
|
}
|
|
60
58
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
59
|
+
* Compare two strings without revealing, through timing, where they start
|
|
60
|
+
* to differ.
|
|
63
61
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
62
|
+
* For the state this is strictly more than necessary — but the function is
|
|
63
|
+
* also here for whoever applies it to something more sensitive.
|
|
66
64
|
*/
|
|
67
|
-
export function
|
|
65
|
+
export function timingSafeEqual(a, b) {
|
|
68
66
|
if (a.length !== b.length)
|
|
69
67
|
return false;
|
|
70
|
-
let
|
|
68
|
+
let difference = 0;
|
|
71
69
|
for (let i = 0; i < a.length; i += 1) {
|
|
72
|
-
|
|
70
|
+
difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
73
71
|
}
|
|
74
|
-
return
|
|
72
|
+
return difference === 0;
|
|
75
73
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,36 +1,35 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** How an application identifies itself to Aplons. */
|
|
2
2
|
export type AplonsOptions = {
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Where Aplons lives, without a path — e.g. `https://auth.aplons.com`.
|
|
5
|
+
* Everything else the package fetches from there itself (`/.well-known/…`).
|
|
6
6
|
*/
|
|
7
7
|
issuer: string;
|
|
8
8
|
clientId: string;
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* sondern nur unübersichtlich.
|
|
10
|
+
* Only for applications that can keep a secret — that is, ones with a
|
|
11
|
+
* server. An application running in a browser or on a phone leaves it out:
|
|
12
|
+
* whatever ships there is not secret, and a "secret" anyone can read does
|
|
13
|
+
* not make things safer, only less clear.
|
|
15
14
|
*/
|
|
16
15
|
clientSecret?: string;
|
|
17
|
-
/**
|
|
16
|
+
/** Where Aplons sends the browser back to. Must be registered. */
|
|
18
17
|
redirectUri: string;
|
|
19
|
-
/**
|
|
18
|
+
/** Defaults to openid, profile, email. */
|
|
20
19
|
scope?: string[];
|
|
21
|
-
/**
|
|
20
|
+
/** Your own fetch implementation, e.g. for tests. */
|
|
22
21
|
fetch?: typeof globalThis.fetch;
|
|
23
22
|
};
|
|
24
|
-
/**
|
|
25
|
-
export type
|
|
23
|
+
/** What you hold after a successful login. */
|
|
24
|
+
export type Session = {
|
|
26
25
|
accessToken: string;
|
|
27
26
|
refreshToken?: string;
|
|
28
|
-
/**
|
|
29
|
-
*
|
|
27
|
+
/** A point in time, not a duration: a duration is wrong the moment you
|
|
28
|
+
* store it. */
|
|
30
29
|
accessTokenExpiresAt: Date;
|
|
31
30
|
idToken?: string;
|
|
32
31
|
scope: string[];
|
|
33
|
-
/**
|
|
32
|
+
/** The verified claims from the ID token. */
|
|
34
33
|
claims?: IdTokenClaims;
|
|
35
34
|
};
|
|
36
35
|
export type IdTokenClaims = {
|
|
@@ -44,29 +43,29 @@ export type IdTokenClaims = {
|
|
|
44
43
|
name?: string;
|
|
45
44
|
given_name?: string;
|
|
46
45
|
family_name?: string;
|
|
47
|
-
/**
|
|
46
|
+
/** The tenant the account belongs to. */
|
|
48
47
|
tid?: string;
|
|
49
|
-
[
|
|
48
|
+
[claim: string]: unknown;
|
|
50
49
|
};
|
|
51
50
|
export type AccessTokenClaims = {
|
|
52
51
|
sub: string;
|
|
53
52
|
iss: string;
|
|
54
53
|
exp: number;
|
|
55
54
|
iat: number;
|
|
56
|
-
/**
|
|
55
|
+
/** The tenant. */
|
|
57
56
|
tid?: string;
|
|
58
|
-
/**
|
|
57
|
+
/** Roles within the tenant. */
|
|
59
58
|
rls?: string[];
|
|
60
|
-
/**
|
|
59
|
+
/** Individual permissions. */
|
|
61
60
|
prm?: string[];
|
|
62
|
-
/**
|
|
61
|
+
/** The session the token came from. */
|
|
63
62
|
sid?: string;
|
|
64
63
|
email?: string;
|
|
65
64
|
scope?: string;
|
|
66
|
-
[
|
|
65
|
+
[claim: string]: unknown;
|
|
67
66
|
};
|
|
68
|
-
/**
|
|
69
|
-
export type
|
|
67
|
+
/** What UserInfo returns — more or less, depending on the scopes. */
|
|
68
|
+
export type UserInfo = {
|
|
70
69
|
sub: string;
|
|
71
70
|
email?: string;
|
|
72
71
|
email_verified?: boolean;
|
|
@@ -74,17 +73,17 @@ export type Profil = {
|
|
|
74
73
|
given_name?: string;
|
|
75
74
|
family_name?: string;
|
|
76
75
|
phone_number?: string;
|
|
77
|
-
/**
|
|
76
|
+
/** Roles in *this* application, with the `roles` scope. */
|
|
78
77
|
roles?: string[];
|
|
79
|
-
/**
|
|
78
|
+
/** This application's own fields, with the `app_profile` scope. */
|
|
80
79
|
app_profile?: Record<string, unknown>;
|
|
81
|
-
[
|
|
80
|
+
[claim: string]: unknown;
|
|
82
81
|
};
|
|
83
|
-
/**
|
|
84
|
-
export type
|
|
85
|
-
/**
|
|
82
|
+
/** What has to survive between starting a login and completing it. */
|
|
83
|
+
export type AuthorizationRequest = {
|
|
84
|
+
/** Send the browser here. */
|
|
86
85
|
url: string;
|
|
87
|
-
/**
|
|
86
|
+
/** Keep all three briefly — and hand them back to `completeLogin`. */
|
|
88
87
|
verifier: string;
|
|
89
88
|
state: string;
|
|
90
89
|
nonce: string;
|
package/dist/verify.d.ts
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Verify a token instead of believing it.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* A JWT is readable without anyone verifying anything — `atob` on the middle
|
|
5
|
+
* part and you are done. Whoever mistakes that for verification lets in
|
|
6
|
+
* everybody who writes themselves a token. So the signature is checked
|
|
7
|
+
* against the public keys of Aplons, along with issuer, audience and expiry.
|
|
8
8
|
*
|
|
9
|
-
* `jose`
|
|
10
|
-
*
|
|
9
|
+
* `jose` does the cryptography. It is this package's only dependency, and it
|
|
10
|
+
* is the same one the Aplons server itself uses.
|
|
11
11
|
*/
|
|
12
12
|
import { createRemoteJWKSet } from "jose";
|
|
13
13
|
import type { AccessTokenClaims, IdTokenClaims } from "./types.js";
|
|
14
|
-
type
|
|
15
|
-
export declare function
|
|
16
|
-
export declare function
|
|
14
|
+
type KeySource = ReturnType<typeof createRemoteJWKSet>;
|
|
15
|
+
export declare function jwksFor(jwksUri: string): KeySource;
|
|
16
|
+
export declare function verifyAccessToken(token: string, options: {
|
|
17
17
|
issuer: string;
|
|
18
|
-
|
|
18
|
+
keys: KeySource;
|
|
19
19
|
}): Promise<AccessTokenClaims>;
|
|
20
|
-
export declare function
|
|
20
|
+
export declare function verifyIdToken(token: string, options: {
|
|
21
21
|
issuer: string;
|
|
22
22
|
audience: string;
|
|
23
23
|
nonce?: string;
|
|
24
|
-
|
|
24
|
+
keys: KeySource;
|
|
25
25
|
}): Promise<IdTokenClaims>;
|
|
26
|
-
export {};
|
|
26
|
+
export type { KeySource };
|
package/dist/verify.js
CHANGED
|
@@ -1,94 +1,94 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Verify a token instead of believing it.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* A JWT is readable without anyone verifying anything — `atob` on the middle
|
|
5
|
+
* part and you are done. Whoever mistakes that for verification lets in
|
|
6
|
+
* everybody who writes themselves a token. So the signature is checked
|
|
7
|
+
* against the public keys of Aplons, along with issuer, audience and expiry.
|
|
8
8
|
*
|
|
9
|
-
* `jose`
|
|
10
|
-
*
|
|
9
|
+
* `jose` does the cryptography. It is this package's only dependency, and it
|
|
10
|
+
* is the same one the Aplons server itself uses.
|
|
11
11
|
*/
|
|
12
12
|
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
13
13
|
import { AplonsError } from "./errors.js";
|
|
14
14
|
/*
|
|
15
|
-
|
|
15
|
+
One key source per address, not per call.
|
|
16
16
|
|
|
17
|
-
`createRemoteJWKSet`
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
Schlüssel erneut zu laden.
|
|
17
|
+
`createRemoteJWKSet` brings its own cache and refetches on an unknown `kid`
|
|
18
|
+
— exactly the behaviour that survives a key rotation. Building a new source
|
|
19
|
+
for every call would throw that cache away and refetch the keys on every
|
|
20
|
+
single request.
|
|
22
21
|
*/
|
|
23
|
-
const
|
|
24
|
-
export function
|
|
25
|
-
let
|
|
26
|
-
if (!
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
const keySources = new Map();
|
|
23
|
+
export function jwksFor(jwksUri) {
|
|
24
|
+
let source = keySources.get(jwksUri);
|
|
25
|
+
if (!source) {
|
|
26
|
+
source = createRemoteJWKSet(new URL(jwksUri));
|
|
27
|
+
keySources.set(jwksUri, source);
|
|
29
28
|
}
|
|
30
|
-
return
|
|
29
|
+
return source;
|
|
31
30
|
}
|
|
32
|
-
export async function
|
|
31
|
+
export async function verifyAccessToken(token, options) {
|
|
33
32
|
try {
|
|
34
|
-
const { payload } = await jwtVerify(token, options.
|
|
33
|
+
const { payload } = await jwtVerify(token, options.keys, {
|
|
35
34
|
issuer: options.issuer,
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
35
|
+
// Deliberately without `audience`: an access token is addressed to the
|
|
36
|
+
// application that uses it, and that can be a different one from the
|
|
37
|
+
// application that obtained it. Anyone wanting it stricter checks
|
|
38
|
+
// `aud` themselves.
|
|
39
39
|
});
|
|
40
40
|
return payload;
|
|
41
41
|
}
|
|
42
42
|
catch (cause) {
|
|
43
|
-
throw
|
|
43
|
+
throw asError(cause, "The access token");
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
-
export async function
|
|
46
|
+
export async function verifyIdToken(token, options) {
|
|
47
47
|
let claims;
|
|
48
48
|
try {
|
|
49
|
-
const { payload } = await jwtVerify(token, options.
|
|
49
|
+
const { payload } = await jwtVerify(token, options.keys, {
|
|
50
50
|
issuer: options.issuer,
|
|
51
|
-
//
|
|
52
|
-
//
|
|
51
|
+
// Here it does apply: an ID token is addressed to exactly this
|
|
52
|
+
// application. One issued for another must not count.
|
|
53
53
|
audience: options.audience,
|
|
54
54
|
});
|
|
55
55
|
claims = payload;
|
|
56
56
|
}
|
|
57
57
|
catch (cause) {
|
|
58
|
-
throw
|
|
58
|
+
throw asError(cause, "The ID token");
|
|
59
59
|
}
|
|
60
60
|
/*
|
|
61
|
-
|
|
61
|
+
The nonce binds the ID token to this one login.
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
Without it, an intercepted ID token that is still valid could be replayed
|
|
64
|
+
in a new flow. Only checked when one was set at the start — otherwise
|
|
65
|
+
there would be nothing to compare against.
|
|
66
66
|
*/
|
|
67
67
|
if (options.nonce && claims.nonce !== options.nonce) {
|
|
68
68
|
throw new AplonsError({
|
|
69
69
|
code: "nonce_mismatch",
|
|
70
|
-
message: "
|
|
71
|
-
"
|
|
70
|
+
message: "The nonce in the ID token does not belong to this login. " +
|
|
71
|
+
"The login will not continue.",
|
|
72
72
|
});
|
|
73
73
|
}
|
|
74
74
|
return claims;
|
|
75
75
|
}
|
|
76
|
-
function
|
|
76
|
+
function asError(cause, what) {
|
|
77
77
|
const code = cause && typeof cause === "object" && "code" in cause
|
|
78
78
|
? String(cause.code)
|
|
79
79
|
: "invalid_token";
|
|
80
|
-
const
|
|
81
|
-
ERR_JWT_EXPIRED: "
|
|
82
|
-
ERR_JWT_CLAIM_VALIDATION_FAILED: "
|
|
83
|
-
ERR_JWS_SIGNATURE_VERIFICATION_FAILED: "
|
|
84
|
-
ERR_JOSE_NOT_SUPPORTED:
|
|
85
|
-
|
|
86
|
-
ERR_JWKS_NO_MATCHING_KEY: "
|
|
87
|
-
"
|
|
80
|
+
const explanations = {
|
|
81
|
+
ERR_JWT_EXPIRED: "has expired. Renew it with the refresh token.",
|
|
82
|
+
ERR_JWT_CLAIM_VALIDATION_FAILED: "was issued for a different issuer or audience.",
|
|
83
|
+
ERR_JWS_SIGNATURE_VERIFICATION_FAILED: "does not carry a valid signature from Aplons.",
|
|
84
|
+
ERR_JOSE_NOT_SUPPORTED: 'uses an algorithm that is not allowed here — with `alg: "none"` that ' +
|
|
85
|
+
"is a self-written token with no signature at all.",
|
|
86
|
+
ERR_JWKS_NO_MATCHING_KEY: "is signed with a key Aplons does not know. If a key was just rotated, " +
|
|
87
|
+
"a second attempt helps.",
|
|
88
88
|
};
|
|
89
89
|
return new AplonsError({
|
|
90
90
|
code,
|
|
91
91
|
cause,
|
|
92
|
-
message: `${
|
|
92
|
+
message: `${what} ${explanations[code] ?? "is not valid."}`,
|
|
93
93
|
});
|
|
94
94
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aplons/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Anmeldung über Aplons in eigenen Anwendungen — OAuth 2.1 mit PKCE, ohne den Ablauf selbst zu schreiben.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -23,9 +23,8 @@
|
|
|
23
23
|
"default": "./dist/next.js"
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"prepublishOnly": "pnpm build"
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
29
28
|
},
|
|
30
29
|
"dependencies": {
|
|
31
30
|
"jose": "^6.1.0"
|
|
@@ -39,5 +38,8 @@
|
|
|
39
38
|
"pkce",
|
|
40
39
|
"authentication",
|
|
41
40
|
"aplons"
|
|
42
|
-
]
|
|
43
|
-
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "rm -rf dist && tsc -p tsconfig.json"
|
|
44
|
+
}
|
|
45
|
+
}
|