@owlmeans/client-auth 0.1.18-rc.10 → 0.1.18-rc.13
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/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/client-auth/SKILL.md +1 -1
- package/build/login/adopt.d.ts +9 -0
- package/build/login/adopt.d.ts.map +1 -1
- package/build/login/adopt.js +11 -0
- package/build/login/adopt.js.map +1 -1
- package/build/login/consts.d.ts +25 -4
- package/build/login/consts.d.ts.map +1 -1
- package/build/login/consts.js +25 -4
- package/build/login/consts.js.map +1 -1
- package/build/login/credit.d.ts +19 -0
- package/build/login/credit.d.ts.map +1 -0
- package/build/login/credit.js +24 -0
- package/build/login/credit.js.map +1 -0
- package/build/login/hook.d.ts +14 -3
- package/build/login/hook.d.ts.map +1 -1
- package/build/login/hook.js +27 -9
- package/build/login/hook.js.map +1 -1
- package/build/login/i18n/be.json +37 -0
- package/build/login/i18n/de.json +37 -0
- package/build/login/i18n/en.json +37 -0
- package/build/login/i18n/es.json +37 -0
- package/build/login/i18n/pl.json +37 -0
- package/build/login/i18n/ru.json +37 -0
- package/build/login/i18n/uk.json +37 -0
- package/build/login/i18n.d.ts +2 -0
- package/build/login/i18n.d.ts.map +1 -0
- package/build/login/i18n.js +25 -0
- package/build/login/i18n.js.map +1 -0
- package/build/login/index.d.ts +7 -0
- package/build/login/index.d.ts.map +1 -1
- package/build/login/index.js +7 -0
- package/build/login/index.js.map +1 -1
- package/build/login/methods.d.ts +25 -0
- package/build/login/methods.d.ts.map +1 -0
- package/build/login/methods.js +97 -0
- package/build/login/methods.js.map +1 -0
- package/build/login/resume.d.ts +20 -0
- package/build/login/resume.d.ts.map +1 -0
- package/build/login/resume.js +33 -0
- package/build/login/resume.js.map +1 -0
- package/build/login/screen.d.ts +4 -0
- package/build/login/screen.d.ts.map +1 -0
- package/build/login/screen.js +63 -0
- package/build/login/screen.js.map +1 -0
- package/build/login/service.d.ts +4 -3
- package/build/login/service.d.ts.map +1 -1
- package/build/login/service.js +57 -4
- package/build/login/service.js.map +1 -1
- package/build/login/surrogate.d.ts +20 -0
- package/build/login/surrogate.d.ts.map +1 -0
- package/build/login/surrogate.js +33 -0
- package/build/login/surrogate.js.map +1 -0
- package/build/login/terms.d.ts +20 -0
- package/build/login/terms.d.ts.map +1 -0
- package/build/login/terms.js +0 -0
- package/build/login/terms.js.map +1 -0
- package/build/login/types.d.ts +123 -1
- package/build/login/types.d.ts.map +1 -1
- package/build/login/types.js +7 -1
- package/build/login/types.js.map +1 -1
- package/build/manager/plugins/basic-ed25519.d.ts.map +1 -1
- package/build/manager/plugins/basic-ed25519.js +1 -0
- package/build/manager/plugins/basic-ed25519.js.map +1 -1
- package/build/manager/plugins/exports.d.ts +2 -0
- package/build/manager/plugins/exports.d.ts.map +1 -1
- package/build/manager/plugins/exports.js +2 -0
- package/build/manager/plugins/exports.js.map +1 -1
- package/build/manager/plugins/index.d.ts +1 -5
- package/build/manager/plugins/index.d.ts.map +1 -1
- package/build/manager/plugins/index.js +9 -1
- package/build/manager/plugins/index.js.map +1 -1
- package/build/manager/plugins/methods.d.ts +10 -0
- package/build/manager/plugins/methods.d.ts.map +1 -0
- package/build/manager/plugins/methods.js +46 -0
- package/build/manager/plugins/methods.js.map +1 -0
- package/build/manager/plugins/re-captcha.d.ts.map +1 -1
- package/build/manager/plugins/re-captcha.js +2 -0
- package/build/manager/plugins/re-captcha.js.map +1 -1
- package/build/manager/plugins/registry.d.ts +20 -0
- package/build/manager/plugins/registry.d.ts.map +1 -0
- package/build/manager/plugins/registry.js +19 -0
- package/build/manager/plugins/registry.js.map +1 -0
- package/build/manager/plugins/tunnel-consumer.d.ts.map +1 -1
- package/build/manager/plugins/tunnel-consumer.js +1 -0
- package/build/manager/plugins/tunnel-consumer.js.map +1 -1
- package/build/manager/plugins/types.d.ts +31 -0
- package/build/manager/plugins/types.d.ts.map +1 -1
- package/package.json +20 -18
- package/src/login/adopt.ts +12 -0
- package/src/login/consts.ts +31 -4
- package/src/login/credit.ts +37 -0
- package/src/login/hook.ts +28 -11
- package/src/login/i18n/be.json +37 -0
- package/src/login/i18n/de.json +37 -0
- package/src/login/i18n/en.json +37 -0
- package/src/login/i18n/es.json +37 -0
- package/src/login/i18n/pl.json +37 -0
- package/src/login/i18n/ru.json +37 -0
- package/src/login/i18n/uk.json +37 -0
- package/src/login/i18n.ts +26 -0
- package/src/login/index.ts +8 -0
- package/src/login/methods.ts +113 -0
- package/src/login/resume.ts +33 -0
- package/src/login/screen.tsx +122 -0
- package/src/login/service.ts +78 -5
- package/src/login/surrogate.ts +45 -0
- package/src/login/terms.ts +0 -0
- package/src/login/types.ts +132 -1
- package/src/manager/plugins/basic-ed25519.tsx +2 -0
- package/src/manager/plugins/exports.ts +2 -0
- package/src/manager/plugins/index.ts +10 -4
- package/src/manager/plugins/methods.ts +52 -0
- package/src/manager/plugins/re-captcha.tsx +3 -0
- package/src/manager/plugins/registry.ts +26 -0
- package/src/manager/plugins/tunnel-consumer.tsx +2 -0
- package/src/manager/plugins/types.ts +32 -0
- package/tests/login.spec.ts +202 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react'
|
|
2
|
+
import type { CSSProperties, FC } from 'react'
|
|
3
|
+
import { useContext } from '@owlmeans/client'
|
|
4
|
+
import type { CommonConfig } from '@owlmeans/config'
|
|
5
|
+
import type { LoginContext, LoginMethod, LoginScreenProps, LoginService } from './types.js'
|
|
6
|
+
import { LOGIN_SERVICE } from './consts.js'
|
|
7
|
+
import { primaryLoginMethod } from './methods.js'
|
|
8
|
+
import { acceptTerms, resolveTerms, termsAccepted } from './terms.js'
|
|
9
|
+
import { resolveCredit } from './credit.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The sign-in screen every application gets, whether or not it registered a styled one.
|
|
13
|
+
*
|
|
14
|
+
* It exists because a relying party must render SOMETHING rather than start a flow on its own, and
|
|
15
|
+
* a relying party may not depend on a UI family. So this is deliberately plain: raw elements and
|
|
16
|
+
* inline styles, no Tailwind class that a consumer's stylesheet would have to scan for, no shadcn
|
|
17
|
+
* primitive a consumer would have to vendor. An application that registers a real screen through
|
|
18
|
+
* `login().registerScreen(...)` never sees it.
|
|
19
|
+
*
|
|
20
|
+
* Because it is the floor rather than the design, it still has to be correct: it offers the same
|
|
21
|
+
* methods, enforces the same terms confirmation, and renders the same credit line.
|
|
22
|
+
*/
|
|
23
|
+
const box: CSSProperties = {
|
|
24
|
+
maxWidth: '24rem', margin: '10vh auto', padding: '1.5rem',
|
|
25
|
+
fontFamily: 'system-ui, sans-serif', lineHeight: 1.5,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const button = (emphasis?: string): CSSProperties => ({
|
|
29
|
+
display: 'block', width: '100%', marginTop: '.5rem', padding: '.625rem 1rem',
|
|
30
|
+
fontSize: '1rem', cursor: 'pointer', borderRadius: '.375rem',
|
|
31
|
+
border: '1px solid currentColor',
|
|
32
|
+
...(emphasis === 'primary' ? { fontWeight: 600 } : {}),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
export const FallbackLoginScreen: FC<LoginScreenProps> = props => {
|
|
36
|
+
const context = useContext() as unknown as LoginContext
|
|
37
|
+
const t = props.translate ?? ((_key: string, defaultValue: string) => defaultValue)
|
|
38
|
+
const cfg = props.config ?? (context.cfg as CommonConfig).security?.auth?.login
|
|
39
|
+
const brand = (context.cfg as CommonConfig).brand
|
|
40
|
+
|
|
41
|
+
const login = context.service<LoginService>(LOGIN_SERVICE)
|
|
42
|
+
const env = login.env()
|
|
43
|
+
const resolved = resolveTerms(props.terms ?? cfg?.terms)
|
|
44
|
+
const credit = resolveCredit(cfg?.credit, brand, context.cfg.service)
|
|
45
|
+
|
|
46
|
+
const [accepted, setAccepted] = useState(() => termsAccepted(resolved))
|
|
47
|
+
const [attempted, setAttempted] = useState(false)
|
|
48
|
+
|
|
49
|
+
const all = login.methods({ context, env })
|
|
50
|
+
const methods = typeof props.methods === 'function'
|
|
51
|
+
? props.methods(all)
|
|
52
|
+
: props.methods ?? all
|
|
53
|
+
const primary = primaryLoginMethod(methods)
|
|
54
|
+
|
|
55
|
+
const blocked = resolved != null && resolved.required && !accepted
|
|
56
|
+
|
|
57
|
+
const select = useCallback((method: LoginMethod) => {
|
|
58
|
+
if (blocked) {
|
|
59
|
+
setAttempted(true)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
void method.start({ context, env })
|
|
63
|
+
}, [blocked, context, env])
|
|
64
|
+
|
|
65
|
+
const onAccept = useCallback((value: boolean) => {
|
|
66
|
+
setAccepted(value)
|
|
67
|
+
setAttempted(false)
|
|
68
|
+
acceptTerms(resolved, value)
|
|
69
|
+
}, [resolved])
|
|
70
|
+
|
|
71
|
+
return <div style={box}>
|
|
72
|
+
<h1 style={{ fontSize: '1.25rem', marginBottom: '.25rem' }}>
|
|
73
|
+
{props.title ?? t('login.title', 'Sign in')}
|
|
74
|
+
</h1>
|
|
75
|
+
<p style={{ opacity: .75, marginTop: 0 }}>
|
|
76
|
+
{props.subtitle ?? t('login.subtitle', 'Choose how you would like to continue.')}
|
|
77
|
+
</p>
|
|
78
|
+
|
|
79
|
+
{methods.length < 1
|
|
80
|
+
? <p role="status">{t('login.empty', 'No sign-in method is configured for this application.')}</p>
|
|
81
|
+
: methods.map(method => <button
|
|
82
|
+
key={method.id} type="button" data-login-method={method.id}
|
|
83
|
+
// `aria-disabled`, never `disabled`: a disabled button swallows the click, so a user who
|
|
84
|
+
// has not confirmed the terms would press it and be told nothing at all.
|
|
85
|
+
aria-disabled={blocked} data-blocked={blocked ? 'true' : undefined}
|
|
86
|
+
style={{ ...button(method.emphasis), opacity: blocked ? .6 : 1 }}
|
|
87
|
+
autoFocus={method.id === primary?.id}
|
|
88
|
+
onClick={() => select(method)}
|
|
89
|
+
>
|
|
90
|
+
{method.label ?? t(`login.method.${method.i18nKey ?? method.id}`, method.id)}
|
|
91
|
+
</button>)}
|
|
92
|
+
|
|
93
|
+
{resolved != null && <p style={{ marginTop: '1rem', fontSize: '.875rem' }}>
|
|
94
|
+
<label>
|
|
95
|
+
<input
|
|
96
|
+
type="checkbox" checked={accepted} data-login-terms
|
|
97
|
+
onChange={event => onAccept(event.target.checked)}
|
|
98
|
+
/>{' '}
|
|
99
|
+
{t('login.terms.agreement', 'I have read and agree to the Terms & Conditions and the Privacy Policy.')}
|
|
100
|
+
</label>
|
|
101
|
+
{' '}
|
|
102
|
+
<a href={resolved.terms} target="_blank" rel="noreferrer noopener">
|
|
103
|
+
{t('login.terms.terms', 'Terms & Conditions')}
|
|
104
|
+
</a>
|
|
105
|
+
{' · '}
|
|
106
|
+
<a href={resolved.privacy} target="_blank" rel="noreferrer noopener">
|
|
107
|
+
{t('login.terms.privacy', 'Privacy Policy')}
|
|
108
|
+
</a>
|
|
109
|
+
</p>}
|
|
110
|
+
|
|
111
|
+
{attempted && blocked && <p role="alert" style={{ color: '#b00', fontSize: '.875rem' }}>
|
|
112
|
+
{t('login.terms.required',
|
|
113
|
+
'Please confirm the Terms & Conditions and the Privacy Policy to continue.')}
|
|
114
|
+
</p>}
|
|
115
|
+
|
|
116
|
+
{props.footer ?? <p style={{ marginTop: '2rem', fontSize: '.75rem', opacity: .7 }}>
|
|
117
|
+
{credit.poweredBy && <span>{t('login.credit.powered', 'Powered by OwlMeans')}</span>}
|
|
118
|
+
{credit.poweredBy && credit.line != null && ' · '}
|
|
119
|
+
{credit.line}
|
|
120
|
+
</p>}
|
|
121
|
+
</div>
|
|
122
|
+
}
|
package/src/login/service.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { createLazyService } from '@owlmeans/context'
|
|
2
2
|
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
3
|
+
import type { CommonConfig } from '@owlmeans/config'
|
|
3
4
|
import { DEFAULT_ALIAS } from './consts.js'
|
|
4
5
|
import { defaultLoginEnv } from './env.js'
|
|
5
|
-
import { adoptToken } from './adopt.js'
|
|
6
|
+
import { adoptToken, revokeToken } from './adopt.js'
|
|
7
|
+
import { resolveLoginMethods } from './methods.js'
|
|
6
8
|
import { LoginOutcome } from './types.js'
|
|
7
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
LoginContext, LoginMethodSource, LoginPlugin, LoginPrecondition, LoginScreenComponent,
|
|
11
|
+
LoginService, LoginServiceAppend
|
|
12
|
+
} from './types.js'
|
|
8
13
|
|
|
9
14
|
/**
|
|
10
15
|
* Login service = plugin host. It holds a registry of login plugins and, on every facade call,
|
|
@@ -19,12 +24,16 @@ import type { LoginContext, LoginPlugin, LoginService, LoginServiceAppend } from
|
|
|
19
24
|
* stage. `context.service()` throws for an uninitialized non-lazy service, which would make
|
|
20
25
|
* `ensureLoginService` fail exactly where apps are told to call it.
|
|
21
26
|
*
|
|
22
|
-
* `begin`
|
|
23
|
-
* microtask boundary, and a `window.open` that lands after the user gesture has
|
|
24
|
-
* handled is eaten by the popup blocker. The same rule binds every plugin's own
|
|
27
|
+
* `begin` and `logout` are deliberately NOT `async`. An async facade method defers the plugin's
|
|
28
|
+
* body past a microtask boundary, and a `window.open` that lands after the user gesture has
|
|
29
|
+
* finished being handled is eaten by the popup blocker. The same rule binds every plugin's own
|
|
30
|
+
* `begin` and `logout`, and it is why a precondition must be synchronous too.
|
|
25
31
|
*/
|
|
26
32
|
export const makeLoginService = (alias: string = DEFAULT_ALIAS): LoginService => {
|
|
27
33
|
const plugins: LoginPlugin[] = []
|
|
34
|
+
const preconditions: LoginPrecondition[] = []
|
|
35
|
+
const methodSources: LoginMethodSource[] = []
|
|
36
|
+
let screen: LoginScreenComponent | null = null
|
|
28
37
|
|
|
29
38
|
const ctx = (): LoginContext => service.ctx as LoginContext
|
|
30
39
|
|
|
@@ -38,6 +47,33 @@ export const makeLoginService = (alias: string = DEFAULT_ALIAS): LoginService =>
|
|
|
38
47
|
plugins.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
|
39
48
|
},
|
|
40
49
|
|
|
50
|
+
registerPrecondition: (precondition: LoginPrecondition) => {
|
|
51
|
+
const existing = preconditions.findIndex(candidate => candidate.alias === precondition.alias)
|
|
52
|
+
if (existing >= 0) {
|
|
53
|
+
preconditions.splice(existing, 1)
|
|
54
|
+
}
|
|
55
|
+
preconditions.push(precondition)
|
|
56
|
+
preconditions.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
registerMethodSource: (source: LoginMethodSource) => {
|
|
60
|
+
const existing = methodSources.findIndex(candidate => candidate.alias === source.alias)
|
|
61
|
+
if (existing >= 0) {
|
|
62
|
+
methodSources.splice(existing, 1)
|
|
63
|
+
}
|
|
64
|
+
methodSources.push(source)
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
methods: methodCtx => resolveLoginMethods(
|
|
68
|
+
methodCtx,
|
|
69
|
+
(methodCtx.context.cfg as CommonConfig).security?.auth?.login,
|
|
70
|
+
methodSources
|
|
71
|
+
),
|
|
72
|
+
|
|
73
|
+
registerScreen: value => { screen = value },
|
|
74
|
+
|
|
75
|
+
screen: () => screen,
|
|
76
|
+
|
|
41
77
|
plugin: env => {
|
|
42
78
|
const environment = env ?? defaultLoginEnv()
|
|
43
79
|
const plugin = plugins.find(
|
|
@@ -58,6 +94,13 @@ export const makeLoginService = (alias: string = DEFAULT_ALIAS): LoginService =>
|
|
|
58
94
|
|
|
59
95
|
begin: request => {
|
|
60
96
|
const env = defaultLoginEnv()
|
|
97
|
+
// Synchronously, before anything can open a window: a precondition that refuses leaves the
|
|
98
|
+
// user exactly where `Gesture` describes — unable to proceed until they act again.
|
|
99
|
+
for (const precondition of preconditions) {
|
|
100
|
+
if (!precondition.check(ctx(), request, env)) {
|
|
101
|
+
return Promise.resolve(LoginOutcome.Gesture)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
61
104
|
|
|
62
105
|
return service.plugin(env).begin(ctx(), request, env)
|
|
63
106
|
},
|
|
@@ -74,7 +117,37 @@ export const makeLoginService = (alias: string = DEFAULT_ALIAS): LoginService =>
|
|
|
74
117
|
return await service.plugin(env).complete(ctx(), token, env)
|
|
75
118
|
},
|
|
76
119
|
|
|
120
|
+
resume: async token => {
|
|
121
|
+
const env = defaultLoginEnv()
|
|
122
|
+
// Absent means "keep it and carry on" — which is what an ordinary tab has always done, and
|
|
123
|
+
// is why the redirect plugin implements nothing here.
|
|
124
|
+
return await service.plugin(env).resume?.(ctx(), token, env) ?? LoginOutcome.Passed
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
logout: request => {
|
|
128
|
+
const env = defaultLoginEnv()
|
|
129
|
+
const plugin = service.plugin(env)
|
|
130
|
+
if (plugin.logout == null) {
|
|
131
|
+
// A plugin with no logout mechanic still has to end the session it started.
|
|
132
|
+
return revokeToken(ctx()).then(async () => {
|
|
133
|
+
await request.navigate?.()
|
|
134
|
+
|
|
135
|
+
return LoginOutcome.Passed
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return plugin.logout(ctx(), request, env)
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
logoutComplete: async () => {
|
|
143
|
+
const env = defaultLoginEnv()
|
|
144
|
+
|
|
145
|
+
return await service.plugin(env).logoutComplete?.(ctx(), env) ?? LoginOutcome.Passed
|
|
146
|
+
},
|
|
147
|
+
|
|
77
148
|
adopt: async token => { await adoptToken(ctx(), token) },
|
|
149
|
+
|
|
150
|
+
revoke: async () => { await revokeToken(ctx()) },
|
|
78
151
|
})
|
|
79
152
|
|
|
80
153
|
return service
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { DISPATCHER_SURROGATE } from '@owlmeans/auth'
|
|
2
|
+
import type { ClientEntrypoint } from '@owlmeans/client-entrypoint'
|
|
3
|
+
import { LOGIN_INTENT_QUERY, LOGIN_METHOD_QUERY, LOGIN_NEXT_QUERY } from './consts.js'
|
|
4
|
+
import { LoginIntent } from './types.js'
|
|
5
|
+
import type { LoginContext } from './types.js'
|
|
6
|
+
|
|
7
|
+
export interface SurrogateTarget {
|
|
8
|
+
intent: LoginIntent
|
|
9
|
+
/** The address the surrogate should actually run, once it is one window up. */
|
|
10
|
+
next?: string
|
|
11
|
+
/** The method the user already chose in the opener. */
|
|
12
|
+
method?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Where a surrogate window opens.
|
|
17
|
+
*
|
|
18
|
+
* Returns `null` when the application's entrypoint list predates the surrogate route — an app
|
|
19
|
+
* built against an older `@owlmeans/auth-common` has no such entrypoint, and the caller then falls
|
|
20
|
+
* back to opening the dispatcher directly, which is exactly what it used to do. That fallback is
|
|
21
|
+
* the whole compatibility story for already-deployed applications, so it must never be removed in
|
|
22
|
+
* favour of throwing.
|
|
23
|
+
*/
|
|
24
|
+
export const surrogatePath = (ctx: LoginContext, target: SurrogateTarget): string | null => {
|
|
25
|
+
if (!ctx.hasEntrypoint(DISPATCHER_SURROGATE)) {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
let path: string
|
|
29
|
+
try {
|
|
30
|
+
path = ctx.entrypoint<ClientEntrypoint>(DISPATCHER_SURROGATE).getPath()
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const query = new URLSearchParams()
|
|
36
|
+
query.set(LOGIN_INTENT_QUERY, target.intent)
|
|
37
|
+
if (target.next != null && target.next !== '') {
|
|
38
|
+
query.set(LOGIN_NEXT_QUERY, target.next)
|
|
39
|
+
}
|
|
40
|
+
if (target.method != null && target.method !== '') {
|
|
41
|
+
query.set(LOGIN_METHOD_QUERY, target.method)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return `${path}?${query.toString()}`
|
|
45
|
+
}
|
|
Binary file
|
package/src/login/types.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
1
2
|
import type { LazyService, BasicContext } from '@owlmeans/context'
|
|
3
|
+
import type { LoginMethodEmphasis, LoginScreenConfig, LoginTermsConfig } from '@owlmeans/config'
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* The context a login plugin is handed.
|
|
@@ -37,12 +39,18 @@ export enum LoginOutcome {
|
|
|
37
39
|
Redirected = 'redirected',
|
|
38
40
|
/** Cannot proceed without a fresh user gesture — the caller renders a sign-in control. */
|
|
39
41
|
Gesture = 'gesture',
|
|
40
|
-
/** Authenticated, but with no channel back to the window that started
|
|
42
|
+
/** Authenticated, but with no channel back to the window that started it. */
|
|
41
43
|
Orphaned = 'orphaned',
|
|
42
44
|
/** The attempt ended with no token (blocked window, user closed it, provider refused). */
|
|
43
45
|
Failed = 'failed',
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
/** Why a surrogate window was opened. */
|
|
49
|
+
export enum LoginIntent {
|
|
50
|
+
Login = 'login',
|
|
51
|
+
Logout = 'logout',
|
|
52
|
+
}
|
|
53
|
+
|
|
46
54
|
export interface LoginRequest {
|
|
47
55
|
/** Where the login flow starts — a resolved dispatcher path, or the current address. */
|
|
48
56
|
url: string
|
|
@@ -56,6 +64,13 @@ export interface LoginRequest {
|
|
|
56
64
|
target?: string
|
|
57
65
|
}
|
|
58
66
|
|
|
67
|
+
export interface LogoutRequest {
|
|
68
|
+
/** Where a surrogate logout runs — a resolved surrogate path. */
|
|
69
|
+
url: string
|
|
70
|
+
/** The in-app continuation once the local session is gone. */
|
|
71
|
+
navigate?: () => void | Promise<void>
|
|
72
|
+
}
|
|
73
|
+
|
|
59
74
|
/**
|
|
60
75
|
* A pluggable login mechanic — WHERE the authorization round trip runs.
|
|
61
76
|
*
|
|
@@ -95,14 +110,125 @@ export interface LoginPlugin {
|
|
|
95
110
|
|
|
96
111
|
/** A bearer token was issued in this document — decide where it goes. */
|
|
97
112
|
complete: (ctx: LoginContext, token: string, env: LoginEnv) => Promise<LoginOutcome>
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* This document ALREADY holds a session — decide whether it is useful here.
|
|
116
|
+
*
|
|
117
|
+
* Absent means `Passed`: keep it and carry on, which is what an ordinary tab has always done.
|
|
118
|
+
* A surrogate hands it back to its opener instead, which is the whole point: a popup that
|
|
119
|
+
* discovers an existing session must sign the framed application in, not display the
|
|
120
|
+
* application to itself.
|
|
121
|
+
*
|
|
122
|
+
* Deliberately not `complete`: "a token was just issued here" and "a token was already here"
|
|
123
|
+
* are different facts, and a silent-refresh plugin will need to tell them apart.
|
|
124
|
+
*/
|
|
125
|
+
resume?: (ctx: LoginContext, token: string, env: LoginEnv) => Promise<LoginOutcome>
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Start logout from a user gesture.
|
|
129
|
+
*
|
|
130
|
+
* MUST open any window synchronously, for exactly the reason `begin` must — logging out of a
|
|
131
|
+
* framed application opens a window too. Implement as a NON-async function.
|
|
132
|
+
*/
|
|
133
|
+
logout?: (ctx: LoginContext, request: LogoutRequest, env: LoginEnv) => Promise<LoginOutcome>
|
|
134
|
+
|
|
135
|
+
/** The local session is gone in this document — decide what to tell whom. */
|
|
136
|
+
logoutComplete?: (ctx: LoginContext, env: LoginEnv) => Promise<LoginOutcome>
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Something that must be true before a login flow may start.
|
|
141
|
+
*
|
|
142
|
+
* SYNCHRONOUS on purpose. `begin` must not cross a microtask boundary before a plugin's
|
|
143
|
+
* `window.open`, or the popup blocker eats the window. A precondition that has to ask a server
|
|
144
|
+
* belongs somewhere else entirely.
|
|
145
|
+
*
|
|
146
|
+
* Returning false stops the flow and resolves `begin` as {@link LoginOutcome.Gesture} — which
|
|
147
|
+
* already means "cannot proceed without a fresh user gesture; render a control", and is exactly
|
|
148
|
+
* the state a user is in after a blocking dialog has opened over the page.
|
|
149
|
+
*/
|
|
150
|
+
export interface LoginPrecondition {
|
|
151
|
+
alias: string
|
|
152
|
+
/** Higher runs first. Defaults to 0. */
|
|
153
|
+
priority?: number
|
|
154
|
+
check: (ctx: LoginContext, request: LoginRequest, env: LoginEnv) => boolean
|
|
98
155
|
}
|
|
99
156
|
|
|
157
|
+
/** What a source needs in order to describe the methods it offers. */
|
|
158
|
+
export interface LoginMethodContext {
|
|
159
|
+
context: LoginContext
|
|
160
|
+
env: LoginEnv
|
|
161
|
+
/** In-app navigation, when a component supplied it. */
|
|
162
|
+
navigate?: (alias: string, params?: Record<string, string>) => void | Promise<void>
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* One way to sign in, as the screen renders it.
|
|
167
|
+
*
|
|
168
|
+
* `start` is what a button calls. It MUST be callable synchronously from a click — branch on
|
|
169
|
+
* `env.embedded && !env.surrogate` first and open any window before the first `await`.
|
|
170
|
+
*/
|
|
171
|
+
export interface LoginMethod {
|
|
172
|
+
id: string
|
|
173
|
+
/** Set when an `AuthenticationPlugin` drives this method. */
|
|
174
|
+
type?: string
|
|
175
|
+
label?: string
|
|
176
|
+
i18nKey?: string
|
|
177
|
+
icon?: string
|
|
178
|
+
order?: number
|
|
179
|
+
emphasis?: LoginMethodEmphasis
|
|
180
|
+
restricted?: boolean
|
|
181
|
+
params?: Record<string, string>
|
|
182
|
+
start: (ctx: LoginMethodContext) => Promise<LoginOutcome>
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface LoginMethodSource {
|
|
186
|
+
alias: string
|
|
187
|
+
list: (ctx: LoginMethodContext) => LoginMethod[]
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** What every rendering of the sign-in screen accepts, whatever its UI family. */
|
|
191
|
+
export interface LoginScreenProps {
|
|
192
|
+
/** The one thing a consuming application is expected to supply. */
|
|
193
|
+
Logo?: ComponentType<{ className?: string }> | ReactNode
|
|
194
|
+
title?: ReactNode
|
|
195
|
+
subtitle?: ReactNode
|
|
196
|
+
/**
|
|
197
|
+
* `(key, defaultValue) => string`. A prop, never an implicit context read: a component that
|
|
198
|
+
* reaches for an i18n provider crashes the whole render in an app mounted without one.
|
|
199
|
+
*/
|
|
200
|
+
translate?: (key: string, defaultValue: string) => string
|
|
201
|
+
/** Replace or reorder what the resolver produced. */
|
|
202
|
+
methods?: LoginMethod[] | ((methods: LoginMethod[]) => LoginMethod[])
|
|
203
|
+
terms?: LoginTermsConfig | false
|
|
204
|
+
config?: LoginScreenConfig
|
|
205
|
+
/** Replaces the composed credit line entirely. */
|
|
206
|
+
footer?: ReactNode
|
|
207
|
+
className?: string
|
|
208
|
+
containerClassName?: string
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export type LoginScreenComponent = ComponentType<LoginScreenProps>
|
|
212
|
+
|
|
100
213
|
export interface LoginService extends LazyService {
|
|
101
214
|
registerPlugin: (plugin: LoginPlugin) => void
|
|
102
215
|
/** Select the active plugin for the given (or default) environment. */
|
|
103
216
|
plugin: (env?: LoginEnv) => LoginPlugin
|
|
104
217
|
/** The environment the cascade is currently selecting on. */
|
|
105
218
|
env: () => LoginEnv
|
|
219
|
+
/** Something that must hold before any flow starts. Checked synchronously, in `begin`. */
|
|
220
|
+
registerPrecondition: (precondition: LoginPrecondition) => void
|
|
221
|
+
/** A source of offerable sign-in methods, scoped to this context. */
|
|
222
|
+
registerMethodSource: (source: LoginMethodSource) => void
|
|
223
|
+
methods: (ctx: LoginMethodContext) => LoginMethod[]
|
|
224
|
+
/**
|
|
225
|
+
* The screen a dispatcher renders when it cannot proceed.
|
|
226
|
+
*
|
|
227
|
+
* A slot rather than an import, because a relying party (`web-oidc-rp`) must never depend on a
|
|
228
|
+
* UI family (`web-panel` / `mui-panel`) — that edge would force every relying party to pick one.
|
|
229
|
+
*/
|
|
230
|
+
registerScreen: (screen: LoginScreenComponent) => void
|
|
231
|
+
screen: () => LoginScreenComponent | null
|
|
106
232
|
// Facade — every method re-selects the plugin and delegates.
|
|
107
233
|
// NOTE: these stay plain writable instance properties, never getters, so that alternative
|
|
108
234
|
// implementations (e.g. a native login service) can monkey-patch them directly.
|
|
@@ -110,8 +236,13 @@ export interface LoginService extends LazyService {
|
|
|
110
236
|
begin: (request: LoginRequest) => Promise<LoginOutcome>
|
|
111
237
|
authorize: (url: string) => Promise<LoginOutcome>
|
|
112
238
|
complete: (token: string) => Promise<LoginOutcome>
|
|
239
|
+
resume: (token: string) => Promise<LoginOutcome>
|
|
240
|
+
logout: (request: LogoutRequest) => Promise<LoginOutcome>
|
|
241
|
+
logoutComplete: () => Promise<LoginOutcome>
|
|
113
242
|
/** Adopt an issued bearer token as this context's authentication. */
|
|
114
243
|
adopt: (token: string) => Promise<void>
|
|
244
|
+
/** Drop this document's authentication. The single de-adoption path. */
|
|
245
|
+
revoke: () => Promise<void>
|
|
115
246
|
}
|
|
116
247
|
|
|
117
248
|
export interface LoginServiceAppend {
|
|
@@ -7,6 +7,8 @@ import { AuthenCredError } from '../errors.js'
|
|
|
7
7
|
export const ed25519BasicUIPlugin: AuthenticationPlugin = {
|
|
8
8
|
type: AuthenticationType.BasicEd25519,
|
|
9
9
|
|
|
10
|
+
method: { order: 300, icon: 'key', emphasis: 'secondary' },
|
|
11
|
+
|
|
10
12
|
Implementation: Renderer => ({ type, stage, control, params }) => {
|
|
11
13
|
|
|
12
14
|
type = type ?? AuthenticationType.BasicEd25519
|
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
import type { ClientAuthType } from '../components/index.js'
|
|
2
|
-
|
|
3
1
|
import { AuthenticationType } from '@owlmeans/auth'
|
|
4
2
|
import { ed25519BasicUIPlugin } from './basic-ed25519.js'
|
|
5
|
-
import { AuthenticationPlugin } from './types.js'
|
|
6
3
|
import { reCaptchaPlugin } from './re-captcha.js'
|
|
7
4
|
import { tunnelConsumerUIPlugin } from './tunnel-consumer.js'
|
|
5
|
+
import { pluginMethodSource } from './methods.js'
|
|
6
|
+
import { registerMethodSource } from '../../login/methods.js'
|
|
7
|
+
import { plugins } from './registry.js'
|
|
8
8
|
|
|
9
|
-
export
|
|
9
|
+
export { plugins, registerAuthPlugin, getAuthPlugin, listAuthPlugins } from './registry.js'
|
|
10
10
|
|
|
11
11
|
plugins[AuthenticationType.BasicEd25519] = ed25519BasicUIPlugin
|
|
12
12
|
plugins[AuthenticationType.ReCaptcha] = reCaptchaPlugin
|
|
13
13
|
plugins[AuthenticationType.WalletConsumer] = tunnelConsumerUIPlugin
|
|
14
|
+
|
|
15
|
+
// Registered from here, not from `login/`, so that importing `@owlmeans/client-auth/login` never
|
|
16
|
+
// drags this module's React plugin implementations into a bundle that only wanted the login host.
|
|
17
|
+
// A generated application imports the login subpath through `client-iam`; it must not gain three
|
|
18
|
+
// authentication methods it never registered as a side effect of that.
|
|
19
|
+
registerMethodSource(pluginMethodSource)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { CAUTHEN_AUTHEN_TYPED } from '@owlmeans/auth'
|
|
2
|
+
import type { ClientEntrypoint } from '@owlmeans/client-entrypoint'
|
|
3
|
+
import { DEFAULT_METHOD_ORDER, LOGIN_SERVICE } from '../../login/consts.js'
|
|
4
|
+
import type { LoginMethod, LoginMethodSource, LoginService } from '../../login/types.js'
|
|
5
|
+
import { listAuthPlugins } from './registry.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every registered authentication plugin that declares itself offerable, as a sign-in method.
|
|
9
|
+
*
|
|
10
|
+
* A plugin without `method` is deliberately invisible here: `re-captcha` is a STEP inside another
|
|
11
|
+
* flow, not a way to sign in, and a registry that offered everything registered would put it on
|
|
12
|
+
* the screen as a choice.
|
|
13
|
+
*/
|
|
14
|
+
export const pluginMethodSource: LoginMethodSource = {
|
|
15
|
+
alias: 'authentication-plugins',
|
|
16
|
+
|
|
17
|
+
list: () => listAuthPlugins()
|
|
18
|
+
.filter(plugin => plugin.method != null && plugin.method.hidden !== true)
|
|
19
|
+
.map((plugin): LoginMethod => {
|
|
20
|
+
const meta = plugin.method!
|
|
21
|
+
const type = plugin.type
|
|
22
|
+
const id = meta.id ?? type
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
id, type,
|
|
26
|
+
...(meta.label != null ? { label: meta.label } : {}),
|
|
27
|
+
i18nKey: meta.i18nKey ?? id,
|
|
28
|
+
...(meta.icon != null ? { icon: meta.icon } : {}),
|
|
29
|
+
order: meta.order ?? DEFAULT_METHOD_ORDER,
|
|
30
|
+
...(meta.emphasis != null ? { emphasis: meta.emphasis } : {}),
|
|
31
|
+
...(meta.restricted === true ? { restricted: true } : {}),
|
|
32
|
+
params: { type },
|
|
33
|
+
|
|
34
|
+
// Non-async through the whole chain: `login.begin` opens the surrogate window in its first
|
|
35
|
+
// statement, and a window opened after the gesture has been handled is blocked.
|
|
36
|
+
start: methodCtx => {
|
|
37
|
+
const login = methodCtx.context.service<LoginService>(LOGIN_SERVICE)
|
|
38
|
+
const entrypoint = methodCtx.context.entrypoint<ClientEntrypoint>(CAUTHEN_AUTHEN_TYPED)
|
|
39
|
+
const url = entrypoint.getPath().replace(':type', encodeURIComponent(type))
|
|
40
|
+
|
|
41
|
+
return login.begin({
|
|
42
|
+
url,
|
|
43
|
+
// In an ordinary tab this keeps the application mounted, exactly as the pre-chooser
|
|
44
|
+
// dispatcher did; the surrogate plugin ignores it and opens a window instead.
|
|
45
|
+
...(methodCtx.navigate != null
|
|
46
|
+
? { navigate: () => methodCtx.navigate?.(CAUTHEN_AUTHEN_TYPED, { type }) }
|
|
47
|
+
: {}),
|
|
48
|
+
})
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}),
|
|
52
|
+
}
|
|
@@ -5,6 +5,9 @@ import { useEffect } from 'react'
|
|
|
5
5
|
export const reCaptchaPlugin: AuthenticationPlugin = {
|
|
6
6
|
type: AuthenticationType.ReCaptcha,
|
|
7
7
|
|
|
8
|
+
// A STEP inside another flow, never a way to sign in — so it is registered and never offered.
|
|
9
|
+
method: { hidden: true },
|
|
10
|
+
|
|
8
11
|
Implementation: Renderer => ({ type, stage, control, params }) => {
|
|
9
12
|
Renderer = Renderer ?? reCaptchaPlugin.Renderer
|
|
10
13
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ClientAuthType } from '../components/authentication/types.js'
|
|
2
|
+
import type { AuthenticationPlugin } from './types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The registry every authentication plugin lands in.
|
|
6
|
+
*
|
|
7
|
+
* It stays a plain exported object, and every registration stays a plain assignment, because four
|
|
8
|
+
* packages already write into it from side-effect imports (`web-oidc-rp`, `mui-oidc-rp`,
|
|
9
|
+
* `web-auth`, and this package's own `index`). Hiding it behind an API would break all of them for
|
|
10
|
+
* no gain — the functions below simply read it.
|
|
11
|
+
*
|
|
12
|
+
* It lives in its own module rather than in `index.ts` so that the method source can read it
|
|
13
|
+
* without the two importing each other.
|
|
14
|
+
*/
|
|
15
|
+
export const plugins: { [type: ClientAuthType]: AuthenticationPlugin } = {}
|
|
16
|
+
|
|
17
|
+
export const registerAuthPlugin = (plugin: AuthenticationPlugin): AuthenticationPlugin => {
|
|
18
|
+
plugins[plugin.type] = plugin
|
|
19
|
+
|
|
20
|
+
return plugin
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const getAuthPlugin = (type: ClientAuthType): AuthenticationPlugin | undefined =>
|
|
24
|
+
plugins[type]
|
|
25
|
+
|
|
26
|
+
export const listAuthPlugins = (): AuthenticationPlugin[] => Object.values(plugins)
|
|
@@ -13,6 +13,8 @@ import { createWalletFacade } from './tunnel/wallet.js'
|
|
|
13
13
|
export const tunnelConsumerUIPlugin: AuthenticationPlugin = {
|
|
14
14
|
type: AuthenticationType.WalletConsumer,
|
|
15
15
|
|
|
16
|
+
method: { order: 400, icon: 'wallet' },
|
|
17
|
+
|
|
16
18
|
Implementation: renderer => ({ type, stage, control, params }) => {
|
|
17
19
|
type = type ?? AuthenticationType.WalletConsumer
|
|
18
20
|
const Renderer: TunnelAuthenticationRenderer | undefined = renderer
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FC } from 'react'
|
|
2
|
+
import type { LoginMethodEmphasis } from '@owlmeans/config'
|
|
2
3
|
import type { AuthenticationControl, AuthenticationRenderer, AuthenticationRendererProps, } from '../components/authentication/types.js'
|
|
3
4
|
|
|
4
5
|
export interface AuthenticationPlugin extends Pick<
|
|
@@ -7,6 +8,37 @@ export interface AuthenticationPlugin extends Pick<
|
|
|
7
8
|
type: string
|
|
8
9
|
Implementation: PluginImplemnetation
|
|
9
10
|
Renderer?: AuthenticationRenderer
|
|
11
|
+
/**
|
|
12
|
+
* How this method presents itself on the sign-in screen.
|
|
13
|
+
*
|
|
14
|
+
* Absent means the plugin is never OFFERED — it is still reachable by type, which is what a
|
|
15
|
+
* step-in-a-flow (re-captcha) and a plugin an app deep-links to both need. Every field is
|
|
16
|
+
* optional so an existing plugin object keeps type-checking without being touched.
|
|
17
|
+
*/
|
|
18
|
+
method?: AuthMethodMeta
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AuthMethodMeta {
|
|
22
|
+
/** Defaults to the plugin's `type`. */
|
|
23
|
+
id?: string
|
|
24
|
+
/** Literal fallback used when no translation resolves. */
|
|
25
|
+
label?: string
|
|
26
|
+
/** Key under the `auth` library's `login.method.*`; defaults to the id. */
|
|
27
|
+
i18nKey?: string
|
|
28
|
+
/** Icon registry NAME, never markup — this package must stay free of an icon library. */
|
|
29
|
+
icon?: string
|
|
30
|
+
/** Ascending; default 100. */
|
|
31
|
+
order?: number
|
|
32
|
+
emphasis?: LoginMethodEmphasis
|
|
33
|
+
/**
|
|
34
|
+
* Never offered unless the configuration explicitly enables it.
|
|
35
|
+
*
|
|
36
|
+
* This is the secret-key gate: an operator login is registered like any other method and must
|
|
37
|
+
* not become offerable merely by being registered.
|
|
38
|
+
*/
|
|
39
|
+
restricted?: boolean
|
|
40
|
+
/** Registered, but never a choice. */
|
|
41
|
+
hidden?: boolean
|
|
10
42
|
}
|
|
11
43
|
|
|
12
44
|
export interface PluginImplemnetation {
|