Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/breezy-rings-poke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'eurosky-portal': patch
---

Fix issue with ATPROTO_HANDLE_DOMAIN being required
5 changes: 5 additions & 0 deletions .changeset/cyan-parents-know.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'eurosky-portal': patch
---

Improve OAuth input resolution to allow bypassing via the authorization server URL
5 changes: 5 additions & 0 deletions .changeset/fluffy-lamps-doubt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'eurosky-portal': patch
---

Add development middleware to ensure correct host for requests
162 changes: 110 additions & 52 deletions app/controllers/oauth_controller.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import type { HttpContext } from '@adonisjs/core/http'
import { Monocle } from '@monocle.sh/adonisjs-agent'
import { OAuthCallbackError, OAuthResolverError } from '@atproto/oauth-client-node'
import { isUriString, asAtIdentifierString, type AtIdentifierString } from '@atproto/lex'
import {
isUriString,
asAtIdentifierString,
type AtIdentifierString,
type UriString,
isHandleString,
} from '@atproto/lex'
import { DateTime } from 'luxon'
import env from '#start/env'
import Account from '#models/account'
import { SlingshotService } from '#services/slingshot_service'
import { loginRequestValidator, signupRequestValidator } from '#validators/oauth'
import { createFieldError } from '#utils/errors'
import { getHandleDomain } from '#utils/oauth'
import { type OAuthResolvedIdentity } from '@thisismissem/adonisjs-atproto-oauth/types'
import { type HandleString, INVALID_HANDLE } from '@atproto/syntax'

const oauthServerUrl = env.get('OAUTH_SERVICE')
const normalizedOAuthServerUrl = oauthServerUrl.replace(/\/$/, '').toLowerCase()
const allowExternalLogins = env.get('ALLOW_EXTERNAL_LOGINS', false)
const handleDomain = getHandleDomain()

Expand Down Expand Up @@ -53,24 +62,11 @@ export default class OAuthController {
this.slingshot = new SlingshotService()
}

async login({ request, inertia, oauth, session, logger }: HttpContext) {
const data = await request.validateUsing(loginRequestValidator, {
meta: {
handleDomain,
},
messagesProvider: {
getMessage(defaultMessage, rule, field) {
if (rule === 'at-handle' || rule === 'at-handle-username') {
return `Please enter a valid Atmosphere account, e.g., username${handleDomain ?? '.bsky.social'}`
}

return defaultMessage.replace(/\{\{\s*field\s*\}\}/, field.getFieldPath())
},
},
})

let input = data.input

private async validateAuthInput(
input: string,
resolver: (input: AtIdentifierString) => Promise<OAuthResolvedIdentity | undefined | null>
): Promise<AtIdentifierString | UriString> {
// Convert username to identifier:
if (!isIdentifier(input) && !isUriString(input)) {
if (!handleDomain) {
throw createFieldError('input', input, 'Please enter a valid Atmosphere account')
Expand All @@ -83,48 +79,101 @@ export default class OAuthController {
input = input.replace('.bluesky.social', '.bsky.social')
}

if (allowExternalLogins !== true) {
// We don't need to resolve these authorization servers, since we know they're not us:
if (WELL_KNOWN_HANDLE_DOMAINS.some((serviceDomain) => input.endsWith(serviceDomain))) {
// Bypass if the input is a URI
if (isUriString(input)) {
// If we don't allow external logins, and the input isn't the oauth server
// URL, prevent login with service URL:
if (
allowExternalLogins !== true &&
// We need to remove any trailing slashes to normalize:
input.toLowerCase().replace(/\/$/, '') !== normalizedOAuthServerUrl
) {
throw createFieldError(
'input',
input,
'Currently the Eurosky portal is only available for Eurosky accounts.'
)
}

if (handleDomain && !input.endsWith(handleDomain)) {
// the validation is accepting handles, dids, and services, so we need to
// assert we only have a handle or did string here:
if (!isIdentifier(input)) {
throw createFieldError('input', input, 'Please enter a valid Atmosphere account')
}
return input
}

const resolved = await oauth
.resolveIdentity(input, AbortSignal.timeout(1000))
.catch((err) => {
logger.error(err, 'Failed to resolveIdentity for handle: %s', input)
return undefined
})
// If we allow external logins, and have a valid identity, don't continue to validation:
if (allowExternalLogins === true && isIdentifier(input)) {
return input
}

if (!resolved) {
throw createFieldError(
'input',
input,
`We couldn't find your Atmosphere account: ${input}, please try again later.`
)
}
// the validation is accepting handles, dids, and services, so we need to
// assert we only have a handle or did string here:
if (!isIdentifier(input)) {
throw createFieldError('input', input, 'Please enter a valid Atmosphere account')
}

if (!resolved || resolved.authorizationServer.toString() !== oauthServerUrl) {
throw createFieldError(
'input',
input,
'Currently the Eurosky portal is only available for Eurosky accounts.'
)
}
if (handleDomain && isHandleString(input)) {
// We don't need to resolve the authorization servers for these handle
// domains, since we know they're not us:
if (WELL_KNOWN_HANDLE_DOMAINS.some((serviceDomain) => input.endsWith(serviceDomain))) {
throw createFieldError(
'input',
input,
'Currently the Eurosky portal is only available for Eurosky accounts.'
)
}

// If there's a handle domain set, and the input ends with the handle domain, don't resolve:
if (input.endsWith(handleDomain)) {
return input
}
}

// Finally, attempt resolution:
const resolved = await resolver(input)

if (!resolved) {
throw createFieldError(
'input',
input,
`We couldn't find your Atmosphere account: ${input}, please try again later, or try logging in with: ${oauthServerUrl}`
)
}

if (resolved.authorizationServer.toString() !== oauthServerUrl) {
throw createFieldError(
'input',
input,
'Currently the Eurosky portal is only available for Eurosky accounts.'
)
}

return input
}

async login({ request, inertia, oauth, session, logger }: HttpContext) {
const data = await request.validateUsing(loginRequestValidator, {
meta: {
handleDomain,
},
messagesProvider: {
getMessage(defaultMessage, rule, field) {
if (rule === 'at-handle' || rule === 'at-handle-username') {
return `Please enter a valid Atmosphere account, e.g., username${handleDomain ?? '.bsky.social'}`
}

return defaultMessage.replace(/\{\{\s*field\s*\}\}/, field.getFieldPath())
},
},
})

let input = await this.validateAuthInput(data.input, (identity) => {
return oauth.resolveIdentity(identity, AbortSignal.timeout(1000)).catch((err) => {
logger.error(err, 'Failed to resolveIdentity for handle: %s', identity)
return undefined
})
}).catch((err) => {
logger.error(err)
throw err
})

session.put('source', 'login')
session.put('handle', input)

Expand Down Expand Up @@ -229,18 +278,27 @@ export default class OAuthController {
const resolved = await oauth
.resolveIdentity(did, AbortSignal.timeout(1000))
.catch((error) => {
// AbortSignal timed out, return nothing:
if (error instanceof DOMException && error.name === 'AbortError') {
return undefined
}

logger.error(error, 'Failed to resolve handle: %s', did)

// Assume this is due to an invalid handle, because they did manage to
// complete an oauth flow:
if (error instanceof OAuthResolverError) {
return {
did: did,
handle: INVALID_HANDLE as HandleString,
}
}

throw error
})

const existingAccount = await Account.findBy({ did })

if (!resolved) {
logger.info({ did }, 'Failed to resolve handle')
}

// If we don't have an existing account and weren't able to resolve, abort:
if (!existingAccount && !resolved) {
await oauth.logout(did)
Expand Down
2 changes: 1 addition & 1 deletion app/exceptions/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export default class HttpExceptionHandler extends ExceptionHandler {
* Add the authenticated user's ID if available
* to identify which user encountered the error
*/
user: ctx.auth.user?.did,
user: ctx.auth?.user?.did,

/**
* Include the IP address for security monitoring
Expand Down
27 changes: 27 additions & 0 deletions app/middleware/app_url_middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import env from '#start/env'
import app from '@adonisjs/core/services/app'

const APP_URL = new URL('/', env.get('APP_URL'))

/**
* Auth middleware is used authenticate HTTP requests and deny
* access to unauthenticated users.
*/
export default class AppUrlMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
if (app.inProduction) {
return next()
}

const requestUrl = ctx.request.url(true)
if (ctx.request.method() === 'GET' && ctx.request.host() !== APP_URL.host) {
const correctedUrl = new URL(requestUrl, APP_URL.toString()).toString()

ctx.response.redirect().toPath(correctedUrl)
}

return next()
}
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@
"pnpm": {
"overrides": {
"axios": "^1.15.0",
"lodash-es": ">=4.18.0"
"lodash-es": "^4.18.0",
"uuid": "^14.0.0"
}
},
"hotHook": {
Expand Down
11 changes: 6 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions start/kernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ router.use([
() => import('@adonisjs/core/bodyparser_middleware'),
() => import('@adonisjs/session/session_middleware'),
() => import('@adonisjs/shield/shield_middleware'),
() => import('#middleware/app_url_middleware'),
() => import('@adonisjs/auth/initialize_auth_middleware'),
() => import('#middleware/silent_auth_middleware'),
() => import('@thisismissem/adonisjs-atproto-oauth/initialize_atproto_auth_middleware'),
Expand Down
Loading