@owlmeans/client-auth 0.1.18-rc.2 → 0.1.18-rc.21

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.
Files changed (170) hide show
  1. package/README.md +6 -6
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/client-auth/SKILL.md +134 -21
  4. package/build/components/dispatcher/component.js +1 -1
  5. package/build/components/dispatcher/component.js.map +1 -1
  6. package/build/{modules.d.ts → entrypoints.d.ts} +2 -2
  7. package/build/entrypoints.d.ts.map +1 -0
  8. package/build/{modules.js → entrypoints.js} +3 -3
  9. package/build/entrypoints.js.map +1 -0
  10. package/build/helper.js +1 -1
  11. package/build/helper.js.map +1 -1
  12. package/build/index.d.ts +1 -1
  13. package/build/index.d.ts.map +1 -1
  14. package/build/index.js +1 -1
  15. package/build/index.js.map +1 -1
  16. package/build/login/adopt.d.ts +24 -0
  17. package/build/login/adopt.d.ts.map +1 -0
  18. package/build/login/adopt.js +28 -0
  19. package/build/login/adopt.js.map +1 -0
  20. package/build/login/consts.d.ts +57 -0
  21. package/build/login/consts.d.ts.map +1 -0
  22. package/build/login/consts.js +57 -0
  23. package/build/login/consts.js.map +1 -0
  24. package/build/login/credit.d.ts +24 -0
  25. package/build/login/credit.d.ts.map +1 -0
  26. package/build/login/credit.js +58 -0
  27. package/build/login/credit.js.map +1 -0
  28. package/build/login/env.d.ts +32 -0
  29. package/build/login/env.d.ts.map +1 -0
  30. package/build/login/env.js +84 -0
  31. package/build/login/env.js.map +1 -0
  32. package/build/login/flow.d.ts +20 -0
  33. package/build/login/flow.d.ts.map +1 -0
  34. package/build/login/flow.js +39 -0
  35. package/build/login/flow.js.map +1 -0
  36. package/build/login/hook.d.ts +37 -0
  37. package/build/login/hook.d.ts.map +1 -0
  38. package/build/login/hook.js +72 -0
  39. package/build/login/hook.js.map +1 -0
  40. package/build/login/i18n/be.json +37 -0
  41. package/build/login/i18n/de.json +37 -0
  42. package/build/login/i18n/en.json +37 -0
  43. package/build/login/i18n/es.json +37 -0
  44. package/build/login/i18n/pl.json +37 -0
  45. package/build/login/i18n/ru.json +37 -0
  46. package/build/login/i18n/uk.json +37 -0
  47. package/build/login/i18n.d.ts +2 -0
  48. package/build/login/i18n.d.ts.map +1 -0
  49. package/build/login/i18n.js +25 -0
  50. package/build/login/i18n.js.map +1 -0
  51. package/build/login/index.d.ts +15 -0
  52. package/build/login/index.d.ts.map +1 -0
  53. package/build/login/index.js +15 -0
  54. package/build/login/index.js.map +1 -0
  55. package/build/login/methods.d.ts +25 -0
  56. package/build/login/methods.d.ts.map +1 -0
  57. package/build/login/methods.js +97 -0
  58. package/build/login/methods.js.map +1 -0
  59. package/build/login/resume.d.ts +30 -0
  60. package/build/login/resume.d.ts.map +1 -0
  61. package/build/login/resume.js +55 -0
  62. package/build/login/resume.js.map +1 -0
  63. package/build/login/screen.d.ts +17 -0
  64. package/build/login/screen.d.ts.map +1 -0
  65. package/build/login/screen.js +76 -0
  66. package/build/login/screen.js.map +1 -0
  67. package/build/login/service.d.ts +31 -0
  68. package/build/login/service.d.ts.map +1 -0
  69. package/build/login/service.js +137 -0
  70. package/build/login/service.js.map +1 -0
  71. package/build/login/surrogate.d.ts +20 -0
  72. package/build/login/surrogate.d.ts.map +1 -0
  73. package/build/login/surrogate.js +33 -0
  74. package/build/login/surrogate.js.map +1 -0
  75. package/build/login/terms.d.ts +20 -0
  76. package/build/login/terms.d.ts.map +1 -0
  77. package/build/login/terms.js +0 -0
  78. package/build/login/terms.js.map +1 -0
  79. package/build/login/types.d.ts +236 -0
  80. package/build/login/types.d.ts.map +1 -0
  81. package/build/login/types.js +23 -0
  82. package/build/login/types.js.map +1 -0
  83. package/build/manager/components/authentication/control.js +3 -3
  84. package/build/manager/components/authentication/control.js.map +1 -1
  85. package/build/manager/components/authentication/types.d.ts +2 -2
  86. package/build/manager/components/authentication/types.d.ts.map +1 -1
  87. package/build/manager/components/tunnel-consumer.js +2 -2
  88. package/build/manager/components/tunnel-consumer.js.map +1 -1
  89. package/build/manager/entrypoints.d.ts +2 -0
  90. package/build/manager/entrypoints.d.ts.map +1 -0
  91. package/build/manager/{modules.js → entrypoints.js} +4 -4
  92. package/build/manager/entrypoints.js.map +1 -0
  93. package/build/manager/plugins/basic-ed25519.d.ts.map +1 -1
  94. package/build/manager/plugins/basic-ed25519.js +5 -0
  95. package/build/manager/plugins/basic-ed25519.js.map +1 -1
  96. package/build/manager/plugins/exports.d.ts +2 -0
  97. package/build/manager/plugins/exports.d.ts.map +1 -1
  98. package/build/manager/plugins/exports.js +2 -0
  99. package/build/manager/plugins/exports.js.map +1 -1
  100. package/build/manager/plugins/index.d.ts +1 -5
  101. package/build/manager/plugins/index.d.ts.map +1 -1
  102. package/build/manager/plugins/index.js +9 -1
  103. package/build/manager/plugins/index.js.map +1 -1
  104. package/build/manager/plugins/methods.d.ts +6 -0
  105. package/build/manager/plugins/methods.d.ts.map +1 -0
  106. package/build/manager/plugins/methods.js +65 -0
  107. package/build/manager/plugins/methods.js.map +1 -0
  108. package/build/manager/plugins/re-captcha.d.ts.map +1 -1
  109. package/build/manager/plugins/re-captcha.js +5 -0
  110. package/build/manager/plugins/re-captcha.js.map +1 -1
  111. package/build/manager/plugins/registry.d.ts +20 -0
  112. package/build/manager/plugins/registry.d.ts.map +1 -0
  113. package/build/manager/plugins/registry.js +19 -0
  114. package/build/manager/plugins/registry.js.map +1 -0
  115. package/build/manager/plugins/tunnel-consumer.d.ts.map +1 -1
  116. package/build/manager/plugins/tunnel-consumer.js +4 -0
  117. package/build/manager/plugins/tunnel-consumer.js.map +1 -1
  118. package/build/manager/plugins/types.d.ts +53 -0
  119. package/build/manager/plugins/types.d.ts.map +1 -1
  120. package/build/service.js +1 -1
  121. package/build/service.js.map +1 -1
  122. package/package.json +36 -27
  123. package/src/components/dispatcher/component.tsx +1 -1
  124. package/src/{modules.ts → entrypoints.ts} +2 -2
  125. package/src/helper.ts +1 -1
  126. package/src/index.ts +1 -1
  127. package/src/login/adopt.ts +31 -0
  128. package/src/login/consts.ts +70 -0
  129. package/src/login/credit.ts +75 -0
  130. package/src/login/env.ts +86 -0
  131. package/src/login/flow.ts +44 -0
  132. package/src/login/hook.ts +80 -0
  133. package/src/login/i18n/be.json +37 -0
  134. package/src/login/i18n/de.json +37 -0
  135. package/src/login/i18n/en.json +37 -0
  136. package/src/login/i18n/es.json +37 -0
  137. package/src/login/i18n/pl.json +37 -0
  138. package/src/login/i18n/ru.json +37 -0
  139. package/src/login/i18n/uk.json +37 -0
  140. package/src/login/i18n.ts +26 -0
  141. package/src/login/index.ts +15 -0
  142. package/src/login/methods.ts +113 -0
  143. package/src/login/resume.ts +56 -0
  144. package/src/login/screen.tsx +136 -0
  145. package/src/login/service.ts +181 -0
  146. package/src/login/surrogate.ts +45 -0
  147. package/src/login/terms.ts +0 -0
  148. package/src/login/types.ts +258 -0
  149. package/src/manager/README.md +1 -1
  150. package/src/manager/components/authentication/control.ts +3 -3
  151. package/src/manager/components/authentication/types.ts +2 -2
  152. package/src/manager/components/tunnel-consumer.tsx +2 -2
  153. package/src/manager/{modules.ts → entrypoints.ts} +3 -3
  154. package/src/manager/plugins/basic-ed25519.tsx +7 -0
  155. package/src/manager/plugins/exports.ts +2 -0
  156. package/src/manager/plugins/index.ts +10 -4
  157. package/src/manager/plugins/methods.ts +74 -0
  158. package/src/manager/plugins/re-captcha.tsx +7 -0
  159. package/src/manager/plugins/registry.ts +26 -0
  160. package/src/manager/plugins/tunnel-consumer.tsx +6 -0
  161. package/src/manager/plugins/types.ts +54 -0
  162. package/src/service.ts +1 -1
  163. package/tests/context.ts +1 -2
  164. package/tests/login.spec.ts +313 -0
  165. package/build/.gitkeep +0 -0
  166. package/build/manager/modules.d.ts +0 -2
  167. package/build/manager/modules.d.ts.map +0 -1
  168. package/build/manager/modules.js.map +0 -1
  169. package/build/modules.d.ts.map +0 -1
  170. package/build/modules.js.map +0 -1
@@ -0,0 +1,56 @@
1
+ import { LoginOutcome } from './types.js'
2
+
3
+ /**
4
+ * The message key a FINISHED sign-in attempt should show, or null when it actually went somewhere.
5
+ *
6
+ * A dispatcher may treat `Passed` as "carry on with the ordinary continuation", because it has one.
7
+ * A screen does not: the user clicked, the document did not move, and nothing rendered — which is
8
+ * indistinguishable from a broken button, and is exactly how a null authorization URL used to
9
+ * present itself. Every outcome that leaves the user looking at the same screen therefore has to
10
+ * say something.
11
+ */
12
+ export const loginAttemptError = (outcome: LoginOutcome | null): string | null => {
13
+ switch (outcome) {
14
+ case LoginOutcome.Gesture:
15
+ // The window never opened. That is the popup blocker, and it has its own copy.
16
+ return 'login.error.blocked'
17
+ case LoginOutcome.Failed:
18
+ case LoginOutcome.Passed:
19
+ return 'login.error.failed'
20
+ default:
21
+ // Handled, Redirected and Orphaned all did something; Orphaned reports itself elsewhere.
22
+ return null
23
+ }
24
+ }
25
+
26
+ /** What a dispatcher does once `login().resume(...)` has answered. */
27
+ export enum ResumeAction {
28
+ /** The plugin took it over — the browser is leaving, or the window is closing. */
29
+ Stop = 'stop',
30
+ /** Render the outcome to the user; there is nothing further to do automatically. */
31
+ Render = 'render',
32
+ /** Ordinary tab: keep the session and carry on to the application. */
33
+ Navigate = 'navigate',
34
+ }
35
+
36
+ /**
37
+ * The one reading of a `resume` outcome, shared by every dispatcher.
38
+ *
39
+ * Exported as a pure function because three dispatchers — `web-client`, `web-oidc-rp` and
40
+ * `mui-oidc-rp` — have to agree on it, and the last time they each held their own copy of a
41
+ * decision they drifted, which is how a popup came to render the application inside itself in two
42
+ * packages at once. It is also the whole rule, so it is testable without a DOM.
43
+ */
44
+ export const resumeAction = (outcome: LoginOutcome): ResumeAction => {
45
+ switch (outcome) {
46
+ case LoginOutcome.Handled:
47
+ case LoginOutcome.Redirected:
48
+ return ResumeAction.Stop
49
+ case LoginOutcome.Orphaned:
50
+ case LoginOutcome.Failed:
51
+ case LoginOutcome.Gesture:
52
+ return ResumeAction.Render
53
+ default:
54
+ return ResumeAction.Navigate
55
+ }
56
+ }
@@ -0,0 +1,136 @@
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 page the card sits in.
13
+ *
14
+ * It carries its own viewport height because this screen is rendered straight out of the
15
+ * dispatcher, into whatever the application happens to have around it — which is usually nothing
16
+ * with a height, so a percentage minimum would resolve to zero and leave the card at the top.
17
+ */
18
+ const page: CSSProperties = {
19
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
20
+ minHeight: '100dvh', padding: '1rem',
21
+ fontFamily: 'system-ui, sans-serif', lineHeight: 1.5,
22
+ }
23
+
24
+ // Centred throughout, so the plain screen and the styled one read the same way.
25
+ const box: CSSProperties = {
26
+ width: '100%', maxWidth: '24rem', padding: '1.5rem', textAlign: 'center',
27
+ }
28
+
29
+ const button = (emphasis?: string): CSSProperties => ({
30
+ display: 'block', width: '100%', marginTop: '.5rem', padding: '.625rem 1rem',
31
+ fontSize: '1rem', cursor: 'pointer', borderRadius: '.375rem',
32
+ border: '1px solid currentColor',
33
+ ...(emphasis === 'primary' ? { fontWeight: 600 } : {}),
34
+ })
35
+
36
+ /**
37
+ * The sign-in screen every application gets, whether or not it registered a styled one.
38
+ *
39
+ * It exists because a relying party must render SOMETHING rather than start a flow on its own, and
40
+ * a relying party may not depend on a UI family. So this is deliberately plain: raw elements and
41
+ * inline styles, no Tailwind class that a consumer's stylesheet would have to scan for, no shadcn
42
+ * primitive a consumer would have to vendor. An application that registers a real screen through
43
+ * `login().registerScreen(...)` never sees it.
44
+ *
45
+ * Because it is the floor rather than the design, it still has to be correct: it offers the same
46
+ * methods, enforces the same terms confirmation, centres the same way, and renders the same
47
+ * credit line.
48
+ */
49
+ export const FallbackLoginScreen: FC<LoginScreenProps> = props => {
50
+ const context = useContext() as unknown as LoginContext
51
+ const t = props.translate ?? ((_key: string, defaultValue: string) => defaultValue)
52
+ const cfg = props.config ?? (context.cfg as CommonConfig).security?.auth?.login
53
+ const brand = (context.cfg as CommonConfig).brand
54
+
55
+ const login = context.service<LoginService>(LOGIN_SERVICE)
56
+ const env = login.env()
57
+ const resolved = resolveTerms(props.terms ?? cfg?.terms)
58
+ const credit = resolveCredit(cfg?.credit, brand, context.cfg.service)
59
+
60
+ const [accepted, setAccepted] = useState(() => termsAccepted(resolved))
61
+ const [attempted, setAttempted] = useState(false)
62
+
63
+ const all = login.methods({ context, env })
64
+ const methods = typeof props.methods === 'function'
65
+ ? props.methods(all)
66
+ : props.methods ?? all
67
+ const primary = primaryLoginMethod(methods)
68
+
69
+ const blocked = resolved != null && resolved.required && !accepted
70
+
71
+ const select = useCallback((method: LoginMethod) => {
72
+ if (blocked) {
73
+ setAttempted(true)
74
+ return
75
+ }
76
+ void method.start({ context, env })
77
+ }, [blocked, context, env])
78
+
79
+ const onAccept = useCallback((value: boolean) => {
80
+ setAccepted(value)
81
+ setAttempted(false)
82
+ acceptTerms(resolved, value)
83
+ }, [resolved])
84
+
85
+ return <div data-login-screen style={page}><div style={box}>
86
+ <h1 style={{ fontSize: '1.25rem', marginBottom: '.25rem' }}>
87
+ {props.title ?? t('login.title', 'Sign in')}
88
+ </h1>
89
+ <p style={{ opacity: .75, marginTop: 0 }}>
90
+ {props.subtitle ?? t('login.subtitle', 'Choose how you would like to continue.')}
91
+ </p>
92
+
93
+ {methods.length < 1
94
+ ? <p role="status">{t('login.empty', 'No sign-in method is configured for this application.')}</p>
95
+ : methods.map(method => <button
96
+ key={method.id} type="button" data-login-method={method.id}
97
+ // `aria-disabled`, never `disabled`: a disabled button swallows the click, so a user who
98
+ // has not confirmed the terms would press it and be told nothing at all.
99
+ aria-disabled={blocked} data-blocked={blocked ? 'true' : undefined}
100
+ style={{ ...button(method.emphasis), opacity: blocked ? .6 : 1 }}
101
+ autoFocus={method.id === primary?.id}
102
+ onClick={() => select(method)}
103
+ >
104
+ {method.label ?? t(`login.method.${method.i18nKey ?? method.id}`, method.id)}
105
+ </button>)}
106
+
107
+ {resolved != null && <p style={{ marginTop: '1rem', fontSize: '.875rem' }}>
108
+ <label>
109
+ <input
110
+ type="checkbox" checked={accepted} data-login-terms
111
+ onChange={event => onAccept(event.target.checked)}
112
+ />{' '}
113
+ {t('login.terms.agreement', 'I have read and agree to the Terms & Conditions and the Privacy Policy.')}
114
+ </label>
115
+ {' '}
116
+ <a href={resolved.terms} target="_blank" rel="noreferrer noopener">
117
+ {t('login.terms.terms', 'Terms & Conditions')}
118
+ </a>
119
+ {' · '}
120
+ <a href={resolved.privacy} target="_blank" rel="noreferrer noopener">
121
+ {t('login.terms.privacy', 'Privacy Policy')}
122
+ </a>
123
+ </p>}
124
+
125
+ {attempted && blocked && <p role="alert" style={{ color: '#b00', fontSize: '.875rem' }}>
126
+ {t('login.terms.required',
127
+ 'Please confirm the Terms & Conditions and the Privacy Policy to continue.')}
128
+ </p>}
129
+
130
+ {props.footer ?? <p style={{ marginTop: '2rem', fontSize: '.75rem', opacity: .7 }}>
131
+ {credit.poweredBy && <span>{t('login.credit.powered', 'Powered by OwlMeans')}</span>}
132
+ {credit.poweredBy && credit.line != null && ' · '}
133
+ {credit.line}
134
+ </p>}
135
+ </div></div>
136
+ }
@@ -0,0 +1,181 @@
1
+ import { createLazyService } from '@owlmeans/context'
2
+ import type { BasicConfig, BasicContext } from '@owlmeans/context'
3
+ import type { CommonConfig } from '@owlmeans/config'
4
+ import { DEFAULT_ALIAS } from './consts.js'
5
+ import { defaultLoginEnv } from './env.js'
6
+ import { adoptToken, revokeToken } from './adopt.js'
7
+ import { resolveLoginMethods } from './methods.js'
8
+ import { LoginOutcome } from './types.js'
9
+ import type {
10
+ LoginContext, LoginMethodSource, LoginPlugin, LoginPrecondition, LoginScreenComponent,
11
+ LoginService, LoginServiceAppend
12
+ } from './types.js'
13
+
14
+ /**
15
+ * Login service = plugin host. It holds a registry of login plugins and, on every facade call,
16
+ * resolves the active plugin by cascade (priority desc, first whose `match(env)` is truthy) and
17
+ * delegates to it.
18
+ *
19
+ * The facade methods stay ordinary writable properties (never getters) so that non-plugin
20
+ * implementations — e.g. a native login service — can keep overriding them directly.
21
+ *
22
+ * It is a LAZY service on purpose. The host carries no async setup, and plugin packages must be
23
+ * able to reach it from an app's `makeContext` — i.e. while the context is still in the Loading
24
+ * stage. `context.service()` throws for an uninitialized non-lazy service, which would make
25
+ * `ensureLoginService` fail exactly where apps are told to call it.
26
+ *
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.
31
+ */
32
+ export const makeLoginService = (alias: string = DEFAULT_ALIAS): LoginService => {
33
+ const plugins: LoginPlugin[] = []
34
+ const preconditions: LoginPrecondition[] = []
35
+ const methodSources: LoginMethodSource[] = []
36
+ let screen: LoginScreenComponent | null = null
37
+
38
+ const ctx = (): LoginContext => service.ctx as LoginContext
39
+
40
+ const service: LoginService = createLazyService<LoginService>(alias, {
41
+ registerPlugin: (plugin: LoginPlugin) => {
42
+ const existing = plugins.findIndex(candidate => candidate.alias === plugin.alias)
43
+ if (existing >= 0) {
44
+ plugins.splice(existing, 1)
45
+ }
46
+ plugins.push(plugin)
47
+ plugins.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
48
+ },
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
+
77
+ plugin: env => {
78
+ const environment = env ?? defaultLoginEnv()
79
+ const plugin = plugins.find(
80
+ candidate => candidate.match?.(environment, service.ctx as LoginContext | undefined) ?? true
81
+ )
82
+ if (plugin == null) {
83
+ throw new Error('login: no plugin matches the current environment')
84
+ }
85
+ return plugin
86
+ },
87
+
88
+ env: () => defaultLoginEnv(),
89
+
90
+ enter: () => {
91
+ const env = defaultLoginEnv()
92
+ service.plugin(env).enter?.(ctx(), env)
93
+ },
94
+
95
+ begin: request => {
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
+ }
104
+
105
+ return service.plugin(env).begin(ctx(), request, env)
106
+ },
107
+
108
+ authorize: async url => {
109
+ const env = defaultLoginEnv()
110
+
111
+ return await service.plugin(env).authorize(ctx(), url, env)
112
+ },
113
+
114
+ complete: async token => {
115
+ const env = defaultLoginEnv()
116
+
117
+ return await service.plugin(env).complete(ctx(), token, env)
118
+ },
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
+
148
+ adopt: async token => { await adoptToken(ctx(), token) },
149
+
150
+ revoke: async () => { await revokeToken(ctx()) },
151
+ })
152
+
153
+ return service
154
+ }
155
+
156
+ /**
157
+ * Idempotently obtain the login service on a context, registering an empty host if none exists
158
+ * yet. Plugin packages call this before `registerPlugin`.
159
+ */
160
+ export const ensureLoginService = (ctx: BasicContext<any>): LoginService => {
161
+ if (ctx.hasService(DEFAULT_ALIAS)) {
162
+ return ctx.service<LoginService>(DEFAULT_ALIAS)
163
+ }
164
+ const service = makeLoginService()
165
+ ctx.registerService(service)
166
+
167
+ return service
168
+ }
169
+
170
+ /** Register the host and expose it as `context.login()`. */
171
+ export const appendLogin = <C extends BasicConfig, T extends BasicContext<C>>(
172
+ ctx: T
173
+ ): T & LoginServiceAppend => {
174
+ ensureLoginService(ctx)
175
+ const contextual = ctx as T & LoginServiceAppend
176
+ contextual.login = () => ctx.service<LoginService>(DEFAULT_ALIAS)
177
+
178
+ return contextual
179
+ }
180
+
181
+ export { LoginOutcome }
@@ -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).path()
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
@@ -0,0 +1,258 @@
1
+ import type { ComponentType, CSSProperties, ReactNode } from 'react'
2
+ import type { LazyService, BasicContext } from '@owlmeans/context'
3
+ import type { LoginMethodEmphasis, LoginScreenConfig, LoginTermsConfig } from '@owlmeans/config'
4
+
5
+ /**
6
+ * The context a login plugin is handed.
7
+ *
8
+ * Deliberately `BasicContext<any>`, matching `RouterService`: a plugin only ever reaches services
9
+ * by alias, and pinning the config parameter would make every concrete app config — each of which
10
+ * adds its own required fields — fail to satisfy it, since `makeContext` is invariant in it.
11
+ */
12
+ export type LoginContext = BasicContext<any>
13
+
14
+ /**
15
+ * Environment descriptor the cascade selects a login plugin on.
16
+ *
17
+ * This is the single source of environment truth — do NOT bake `window.self !== window.top` or a
18
+ * `sessionStorage` probe into a plugin's `match`. A native host will supply its own descriptor
19
+ * (`hasWindow: false`) so an in-app-browser plugin wins there without any DOM check.
20
+ */
21
+ export interface LoginEnv {
22
+ /** A DOM is present at all (false under SSR, native, and unit tests). */
23
+ hasWindow: boolean
24
+ /** This document is embedded in a frame. A cross-origin `top` read throws — that throw counts. */
25
+ embedded: boolean
26
+ /** This document IS the surrogate login window; survives the cross-origin round trip. */
27
+ surrogate: boolean
28
+ /** The window that opened this one is still reachable (COOP severs it permanently). */
29
+ hasOpener: boolean
30
+ }
31
+
32
+ /** What a stage did, and therefore what the caller must do next. */
33
+ export enum LoginOutcome {
34
+ /** The plugin took the flow over and it is finished. The caller does nothing more. */
35
+ Handled = 'handled',
36
+ /** The plugin did nothing — the caller carries on with its ordinary continuation. */
37
+ Passed = 'passed',
38
+ /** The browser is leaving this document. The caller must not navigate or render. */
39
+ Redirected = 'redirected',
40
+ /** Cannot proceed without a fresh user gesture — the caller renders a sign-in control. */
41
+ Gesture = 'gesture',
42
+ /** Authenticated, but with no channel back to the window that started it. */
43
+ Orphaned = 'orphaned',
44
+ /** The attempt ended with no token (blocked window, user closed it, provider refused). */
45
+ Failed = 'failed',
46
+ }
47
+
48
+ /** Why a surrogate window was opened. */
49
+ export enum LoginIntent {
50
+ Login = 'login',
51
+ Logout = 'logout',
52
+ }
53
+
54
+ export interface LoginRequest {
55
+ /** Where the login flow starts — a resolved dispatcher path, or the current address. */
56
+ url: string
57
+ /**
58
+ * The ordinary in-app continuation, supplied by the caller because only a component may call
59
+ * `useNavigate`. A plugin that decides not to take the flow over calls this; without it the
60
+ * fallback is a full page load of {@link url}.
61
+ */
62
+ navigate?: () => void | Promise<void>
63
+ /** Entrypoint alias to return to after login. */
64
+ target?: string
65
+ }
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
+
74
+ /**
75
+ * A pluggable login mechanic — WHERE the authorization round trip runs.
76
+ *
77
+ * Not to be confused with `AuthenticationPlugin` (./manager/plugins), which answers *how* a user
78
+ * proves their identity. This one answers *in which browsing context the flow can complete*: a
79
+ * redirect works in an ordinary tab and cannot work inside a frame whose provider refuses to be
80
+ * embedded, and that difference is a property of the environment, not of the credential.
81
+ */
82
+ export interface LoginPlugin {
83
+ alias: string
84
+ /** Higher wins among matching plugins. Defaults to 0. */
85
+ priority?: number
86
+ /** Free-form tag, e.g. 'redirect' | 'surrogate' | 'native'. */
87
+ mode?: string
88
+ /** Selector; `undefined` means "always applicable". */
89
+ match?: (env: LoginEnv, ctx?: LoginContext) => boolean
90
+
91
+ /**
92
+ * Record, while it is still knowable, whatever the later stages need about this document.
93
+ *
94
+ * Called synchronously as the FIRST statement of the dispatcher's effect: everything after it
95
+ * can navigate away, and some of the evidence (notably `window.name`) is cleared by the browser
96
+ * the moment a top-level context goes cross-origin.
97
+ */
98
+ enter?: (ctx: LoginContext, env: LoginEnv) => void
99
+
100
+ /**
101
+ * Start login from a user gesture.
102
+ *
103
+ * MUST open any window synchronously — the popup blocker only yields while the gesture is still
104
+ * being handled. Implement as a NON-async function returning a promise.
105
+ */
106
+ begin: (ctx: LoginContext, request: LoginRequest, env: LoginEnv) => Promise<LoginOutcome>
107
+
108
+ /** Send the browser to the identity provider's authorization URL. */
109
+ authorize: (ctx: LoginContext, url: string, env: LoginEnv) => Promise<LoginOutcome>
110
+
111
+ /** A bearer token was issued in this document — decide where it goes. */
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
155
+ }
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
+ * Inline overrides for the screen's outer box.
211
+ *
212
+ * It exists because the outer box carries its viewport height inline rather than as a utility
213
+ * class, and an escape hatch that a class can no longer provide has to be provided some other
214
+ * way. See the note on the screen itself.
215
+ */
216
+ style?: CSSProperties
217
+ }
218
+
219
+ export type LoginScreenComponent = ComponentType<LoginScreenProps>
220
+
221
+ export interface LoginService extends LazyService {
222
+ registerPlugin: (plugin: LoginPlugin) => void
223
+ /** Select the active plugin for the given (or default) environment. */
224
+ plugin: (env?: LoginEnv) => LoginPlugin
225
+ /** The environment the cascade is currently selecting on. */
226
+ env: () => LoginEnv
227
+ /** Something that must hold before any flow starts. Checked synchronously, in `begin`. */
228
+ registerPrecondition: (precondition: LoginPrecondition) => void
229
+ /** A source of offerable sign-in methods, scoped to this context. */
230
+ registerMethodSource: (source: LoginMethodSource) => void
231
+ methods: (ctx: LoginMethodContext) => LoginMethod[]
232
+ /**
233
+ * The screen a dispatcher renders when it cannot proceed.
234
+ *
235
+ * A slot rather than an import, because a relying party (`web-oidc-rp`) must never depend on a
236
+ * UI family (`web-panel` / `mui-panel`) — that edge would force every relying party to pick one.
237
+ */
238
+ registerScreen: (screen: LoginScreenComponent) => void
239
+ screen: () => LoginScreenComponent | null
240
+ // Facade — every method re-selects the plugin and delegates.
241
+ // NOTE: these stay plain writable instance properties, never getters, so that alternative
242
+ // implementations (e.g. a native login service) can monkey-patch them directly.
243
+ enter: () => void
244
+ begin: (request: LoginRequest) => Promise<LoginOutcome>
245
+ authorize: (url: string) => Promise<LoginOutcome>
246
+ complete: (token: string) => Promise<LoginOutcome>
247
+ resume: (token: string) => Promise<LoginOutcome>
248
+ logout: (request: LogoutRequest) => Promise<LoginOutcome>
249
+ logoutComplete: () => Promise<LoginOutcome>
250
+ /** Adopt an issued bearer token as this context's authentication. */
251
+ adopt: (token: string) => Promise<void>
252
+ /** Drop this document's authentication. The single de-adoption path. */
253
+ revoke: () => Promise<void>
254
+ }
255
+
256
+ export interface LoginServiceAppend {
257
+ login: () => LoginService
258
+ }
@@ -37,7 +37,7 @@ Comprehensive error handling for authentication scenarios:
37
37
  This manager is part of the `@owlmeans/client-auth` package:
38
38
 
39
39
  ```bash
40
- npm install @owlmeans/client-auth
40
+ npm install @owlmeans/client-auth@^0.1.18-rc.19
41
41
  ```
42
42
 
43
43
  ## API Reference